From 207f04f8d234ed3fe16f7c59767189403b85a1f1 Mon Sep 17 00:00:00 2001 From: Bridger Tower Date: Tue, 1 Sep 2026 16:31:35 -0600 Subject: [PATCH 01/17] feat: add Router Forms MVP --- .env.example | 13 + .github/workflows/ci.yml | 79 ++ .gitignore | 5 +- README.md | 2 + __tests__/embed-runtime.test.ts | 104 ++ __tests__/entitlements.test.ts | 40 + __tests__/forms-db.integration.test.ts | 97 ++ __tests__/forms-definition.test.ts | 131 ++ __tests__/forms-security.test.ts | 69 + __tests__/stripe-subscription-state.test.ts | 67 + __tests__/usage-notifications.test.ts | 20 + __tests__/wordpress-token.test.ts | 23 + app/api/cron/route.ts | 4 +- app/api/endpoints/[id]/route.ts | 386 ++--- app/api/integrations/wordpress/forms/route.ts | 28 + .../public/forms/[publicId]/leads/route.ts | 155 ++ .../forms/[publicId]/render-session/route.ts | 84 ++ app/api/public/forms/[publicId]/route.ts | 68 + app/api/webhooks/stripe/route.ts | 206 ++- app/endpoints/[id]/page.tsx | 30 + app/f/[publicId]/page.tsx | 26 + app/forms/[id]/leads/page.tsx | 45 + app/forms/[id]/page.tsx | 27 + app/forms/create/page.tsx | 26 + app/forms/page.tsx | 81 ++ app/forms/wordpress/page.tsx | 21 + app/globals.css | 10 + app/page.tsx | 24 +- app/upgrade/page.tsx | 2 + app/upgrade/plan-tiles.tsx | 294 ++-- components/groups/forms/create-form.tsx | 133 ++ components/groups/forms/form-editor.tsx | 720 ++++++++++ .../groups/forms/wordpress-connections.tsx | 102 ++ components/parts/nav.tsx | 14 +- components/parts/usage.tsx | 8 +- docs/forms/README.md | 38 + docs/forms/legacy-customer-email-drafts.md | 33 + docs/forms/release-runbook.md | 53 + dogfood-output/report.md | 75 + dogfood-output/runtime-fixture.html | 42 + dogfood-output/screenshots/desktop-fixed.png | Bin 0 -> 57415 bytes .../screenshots/desktop-initial.png | Bin 0 -> 56668 bytes .../screenshots/mobile-reduced-motion.png | Bin 0 -> 82139 bytes integrations/wordpress/check.sh | 9 + integrations/wordpress/package.sh | 10 + .../wordpress/router-forms/block.json | 27 + integrations/wordpress/router-forms/editor.js | 84 ++ .../wordpress/router-forms/readme.txt | 19 + .../wordpress/router-forms/render.php | 7 + .../wordpress/router-forms/router-forms.php | 163 +++ lib/analytics/server.ts | 26 + lib/auth/index.ts | 12 +- lib/auth/verification.ts | 4 +- lib/constants/stripe.ts | 101 +- lib/data/endpoints.ts | 15 +- lib/data/forms.ts | 445 ++++++ lib/data/leads.ts | 22 +- lib/data/safe-action.ts | 2 +- lib/data/stripe.ts | 71 +- lib/data/users.ts | 31 +- lib/data/validations.ts | 12 +- lib/data/wordpress.ts | 157 +++ lib/db/drizzle/0006_router_forms_mvp.sql | 93 ++ .../0007_form_attachment_provenance.sql | 1 + .../drizzle/0008_stripe_migration_state.sql | 1 + .../0009_form_origin_kind_uniqueness.sql | 2 + .../0010_placement_first_lead_analytics.sql | 9 + lib/db/drizzle/meta/0006_snapshot.json | 1175 +++++++++++++++ lib/db/drizzle/meta/0007_snapshot.json | 1182 ++++++++++++++++ lib/db/drizzle/meta/0008_snapshot.json | 1189 ++++++++++++++++ lib/db/drizzle/meta/0009_snapshot.json | 1195 ++++++++++++++++ lib/db/drizzle/meta/0010_snapshot.json | 1256 +++++++++++++++++ lib/db/drizzle/meta/_journal.json | 35 + lib/db/index.ts | 24 +- lib/db/migrate.ts | 15 +- lib/db/schema.ts | 209 ++- lib/forms/cache.ts | 8 + lib/forms/definition.ts | 424 ++++++ lib/forms/endpoint-schema.ts | 128 ++ lib/forms/entitlements.ts | 72 + lib/forms/feature-flags.ts | 7 + lib/forms/lead-acceptance.ts | 368 +++++ lib/forms/origins.ts | 31 + lib/forms/public-access.ts | 37 + lib/forms/rate-limit.ts | 98 ++ lib/forms/starters.ts | 141 ++ lib/forms/stripe-subscription-state.ts | 59 + lib/forms/submission-token.ts | 99 ++ lib/forms/usage-notifications.ts | 41 + lib/forms/wordpress-token.ts | 21 + lib/types.d.ts | 16 +- lib/utils/resend.ts | 9 +- lib/utils/stripe-client.ts | 12 + lib/validation/index.ts | 5 + middleware.ts | 27 +- package.json | 9 +- pnpm-lock.yaml | 25 +- public/downloads/router-forms.zip | Bin 0 -> 4833 bytes public/embed/v1.js | 356 +++++ scripts/stripe-legacy-migration.ts | 58 + 100 files changed, 12299 insertions(+), 740 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 __tests__/embed-runtime.test.ts create mode 100644 __tests__/entitlements.test.ts create mode 100644 __tests__/forms-db.integration.test.ts create mode 100644 __tests__/forms-definition.test.ts create mode 100644 __tests__/forms-security.test.ts create mode 100644 __tests__/stripe-subscription-state.test.ts create mode 100644 __tests__/usage-notifications.test.ts create mode 100644 __tests__/wordpress-token.test.ts create mode 100644 app/api/integrations/wordpress/forms/route.ts create mode 100644 app/api/public/forms/[publicId]/leads/route.ts create mode 100644 app/api/public/forms/[publicId]/render-session/route.ts create mode 100644 app/api/public/forms/[publicId]/route.ts create mode 100644 app/f/[publicId]/page.tsx create mode 100644 app/forms/[id]/leads/page.tsx create mode 100644 app/forms/[id]/page.tsx create mode 100644 app/forms/create/page.tsx create mode 100644 app/forms/page.tsx create mode 100644 app/forms/wordpress/page.tsx create mode 100644 components/groups/forms/create-form.tsx create mode 100644 components/groups/forms/form-editor.tsx create mode 100644 components/groups/forms/wordpress-connections.tsx create mode 100644 docs/forms/README.md create mode 100644 docs/forms/legacy-customer-email-drafts.md create mode 100644 docs/forms/release-runbook.md create mode 100644 dogfood-output/report.md create mode 100644 dogfood-output/runtime-fixture.html create mode 100644 dogfood-output/screenshots/desktop-fixed.png create mode 100644 dogfood-output/screenshots/desktop-initial.png create mode 100644 dogfood-output/screenshots/mobile-reduced-motion.png create mode 100755 integrations/wordpress/check.sh create mode 100755 integrations/wordpress/package.sh create mode 100644 integrations/wordpress/router-forms/block.json create mode 100644 integrations/wordpress/router-forms/editor.js create mode 100644 integrations/wordpress/router-forms/readme.txt create mode 100644 integrations/wordpress/router-forms/render.php create mode 100644 integrations/wordpress/router-forms/router-forms.php create mode 100644 lib/analytics/server.ts create mode 100644 lib/data/forms.ts create mode 100644 lib/data/wordpress.ts create mode 100644 lib/db/drizzle/0006_router_forms_mvp.sql create mode 100644 lib/db/drizzle/0007_form_attachment_provenance.sql create mode 100644 lib/db/drizzle/0008_stripe_migration_state.sql create mode 100644 lib/db/drizzle/0009_form_origin_kind_uniqueness.sql create mode 100644 lib/db/drizzle/0010_placement_first_lead_analytics.sql create mode 100644 lib/db/drizzle/meta/0006_snapshot.json create mode 100644 lib/db/drizzle/meta/0007_snapshot.json create mode 100644 lib/db/drizzle/meta/0008_snapshot.json create mode 100644 lib/db/drizzle/meta/0009_snapshot.json create mode 100644 lib/db/drizzle/meta/0010_snapshot.json create mode 100644 lib/forms/cache.ts create mode 100644 lib/forms/definition.ts create mode 100644 lib/forms/endpoint-schema.ts create mode 100644 lib/forms/entitlements.ts create mode 100644 lib/forms/feature-flags.ts create mode 100644 lib/forms/lead-acceptance.ts create mode 100644 lib/forms/origins.ts create mode 100644 lib/forms/public-access.ts create mode 100644 lib/forms/rate-limit.ts create mode 100644 lib/forms/starters.ts create mode 100644 lib/forms/stripe-subscription-state.ts create mode 100644 lib/forms/submission-token.ts create mode 100644 lib/forms/usage-notifications.ts create mode 100644 lib/forms/wordpress-token.ts create mode 100644 lib/utils/stripe-client.ts create mode 100644 public/downloads/router-forms.zip create mode 100644 public/embed/v1.js create mode 100644 scripts/stripe-legacy-migration.ts diff --git a/.env.example b/.env.example index 20004be..c0da289 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ RESEND_API_KEY=your_resend_api_key_here +ROUTER_EMAIL_FROM=info@router.so +ROUTER_APP_URL=http://localhost:3000 AUTH_SECRET=your_nextauth_secret_here +FORM_SUBMISSION_SECRET=replace_with_a_long_random_secret +FORMS_NAV_ENABLED=false +FORMS_PUBLIC_ENABLED=true NODE_ENV=development # optional if you want to do GitHub OAuth @@ -13,3 +18,11 @@ POSTGRES_URL="postgres://user:password@host:port/database?sslmode=require" # You can get your PostHog key from https://app.posthog.com/organization/settings NEXT_PUBLIC_POSTHOG_KEY=your_posthog_key_here NEXT_PUBLIC_POSTHOG_HOST=app.posthog.com + +# Stripe is optional for credential-free builds. Configure these only when enabling billing. +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PRO_MONTHLY_PRICE_ID=price_... +STRIPE_PRO_ANNUAL_PRICE_ID=price_... +STRIPE_BUSINESS_MONTHLY_PRICE_ID=price_... +STRIPE_BUSINESS_ANNUAL_PRICE_ID=price_... diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..365c61e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + application: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9.15.9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test:unit + - name: Credential-free production build + run: pnpm build + env: + AUTH_SECRET: credential-free-build-secret-for-ci-only + FORM_SUBMISSION_SECRET: credential-free-form-secret-for-ci-only + FORM_RATE_LIMIT_SECRET: credential-free-rate-secret-for-ci-only + + postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: router_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9.15.9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Apply forward migrations + env: + PGURL: postgresql://postgres:postgres@localhost:5432/router_test + run: | + for migration in lib/db/drizzle/*.sql; do + psql "$PGURL" -v ON_ERROR_STOP=1 -f "$migration" + done + - run: pnpm test:db + env: + TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/router_test + + wordpress: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - uses: shivammathur/setup-php@v2 + with: + php-version: "7.4" + - run: chmod +x integrations/wordpress/check.sh integrations/wordpress/package.sh + - run: integrations/wordpress/check.sh diff --git a/.gitignore b/.gitignore index 9dbdb7f..8574637 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ yarn-error.log* next-env.d.ts .vscode -.zshrc \ No newline at end of file +.zshrc + +# generated WordPress release artifacts (the public release ZIP is tracked) +/integrations/wordpress/dist/ diff --git a/README.md b/README.md index 9988581..467d390 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ This is a simple router for forms. [Watch a Demo](https://x.com/youngbloodcyb/status/1831808232966516972) +Router supports optional first-class forms without changing its headless endpoint contract. Forms can be published on `forms.router.so`, embedded in an approved website, or rendered through the included WordPress block and shortcode. See [the Forms implementation notes](docs/forms/README.md) and [release runbook](docs/forms/release-runbook.md). + # Self-Hosting router ## Prerequisites diff --git a/__tests__/embed-runtime.test.ts b/__tests__/embed-runtime.test.ts new file mode 100644 index 0000000..f598940 --- /dev/null +++ b/__tests__/embed-runtime.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { FormDefinitionV1 } from "../lib/forms/definition"; +import { FORM_STARTERS } from "../lib/forms/starters"; + +const runtimeSource = readFileSync(resolve("public/embed/v1.js"), "utf8"); + +const definition: FormDefinitionV1 = { + version: 1, + title: "All fields", + description: "Runtime coverage", + submitLabel: "Send", + completion: { type: "message", message: "Thanks" }, + fields: [ + { id: "text", key: "text", kind: "text", label: "Text", required: true }, + { id: "email", key: "email", kind: "email", label: "Email", required: true }, + { id: "phone", key: "phone", kind: "phone", label: "Phone", required: false }, + { id: "url", key: "url", kind: "url", label: "URL", required: false }, + { id: "date", key: "date", kind: "date", label: "Date", required: false }, + { id: "number", key: "number", kind: "number", label: "Number", required: false }, + { id: "textarea", key: "textarea", kind: "textarea", label: "Long text", required: false }, + { id: "select", key: "select", kind: "select", label: "Select", required: false, options: [{ id: "select_a", label: "A", value: "a" }] }, + { id: "radio", key: "radio", kind: "radio", label: "Radio", required: false, options: [{ id: "radio_a", label: "A", value: "a" }] }, + { id: "checkbox", key: "checkbox", kind: "checkbox", label: "Checkbox", required: false }, + { id: "group", key: "group", kind: "checkbox-group", label: "Group", required: false, options: [{ id: "group_a", label: "A", value: "a" }] }, + { id: "yesno", key: "yesno", kind: "yes-no", label: "Yes or no", required: false }, + { id: "switch", key: "switch", kind: "switch", label: "Switch", required: false }, + { id: "slider", key: "slider", kind: "slider", label: "Slider", required: false, validation: { min: 1, max: 10, step: 1 } }, + ], +}; + +describe("embed v1 runtime", () => { + beforeEach(() => { + document.head.innerHTML = ""; + document.body.innerHTML = ""; + delete (window as unknown as { RouterFormsV1?: unknown }).RouterFormsV1; + window.eval(runtimeSource); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders all field kinds with native labels and no framework wrapper", async () => { + const target = document.createElement("div"); + document.body.appendChild(target); + const runtime = (window as unknown as { + RouterFormsV1: { mount: (target: Element, options: object) => Promise }; + }).RouterFormsV1; + + await runtime.mount(target, { definition, publicId: "preview", preview: true }); + + expect(target.querySelector("h2")?.textContent).toBe("All fields"); + expect(target.querySelectorAll("[data-router-field]")).toHaveLength(14); + expect(target.querySelector('input[type="email"]')).not.toBeNull(); + expect(target.querySelector('input[type="range"]')).not.toBeNull(); + expect(target.querySelectorAll("[required]").length).toBeGreaterThan(0); + expect(target.querySelectorAll("fieldset > legend")).toHaveLength(3); + expect(target.querySelector("[data-reactroot]")).toBeNull(); + }); + + it("mounts multiple previews independently and installs scoped styles once", async () => { + const first = document.createElement("div"); + const second = document.createElement("div"); + document.body.append(first, second); + const runtime = (window as unknown as { + RouterFormsV1: { mount: (target: Element, options: object) => Promise }; + }).RouterFormsV1; + + await Promise.all([ + runtime.mount(first, { definition, publicId: "one", preview: true }), + runtime.mount(second, { definition, publicId: "two", preview: true }), + ]); + + expect(first.querySelector("form")).not.toBeNull(); + expect(second.querySelector("form")).not.toBeNull(); + expect(document.querySelectorAll("#router-forms-v1-styles")).toHaveLength(1); + }); + + it.each(Object.entries(FORM_STARTERS))( + "renders the %s starter through the production runtime", + async (_starterId, starter) => { + const target = document.createElement("div"); + document.body.appendChild(target); + const runtime = (window as unknown as { + RouterFormsV1: { + mount: (target: Element, options: object) => Promise; + }; + }).RouterFormsV1; + + await runtime.mount(target, { + definition: starter, + publicId: `starter-${_starterId}`, + preview: true, + }); + + expect(target.querySelector("form")).not.toBeNull(); + expect(target.querySelectorAll("[data-router-field]")).toHaveLength( + starter.fields.length + ); + } + ); +}); diff --git a/__tests__/entitlements.test.ts b/__tests__/entitlements.test.ts new file mode 100644 index 0000000..ba11a88 --- /dev/null +++ b/__tests__/entitlements.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + ENTITLEMENTS, + getCapacityState, + getEntitlement, +} from "../lib/forms/entitlements"; + +describe("Forms entitlements", () => { + it("exposes the approved public plans and allowances", () => { + expect(ENTITLEMENTS.free).toMatchObject({ + monthlyPrice: 0, + monthlyLeads: 100, + showAttribution: true, + }); + expect(ENTITLEMENTS.pro).toMatchObject({ + monthlyPrice: 19, + annualPrice: 190, + monthlyLeads: 10_000, + showAttribution: false, + }); + expect(ENTITLEMENTS.business).toMatchObject({ + monthlyPrice: 49, + annualPrice: 490, + monthlyLeads: 50_000, + showAttribution: false, + }); + }); + + it("keeps the legacy Lite entitlement until its subscription expires", () => { + expect(getEntitlement("lite")).toMatchObject({ monthlyLeads: 1_000 }); + }); + + it("warns at 80 and 100 percent, accepts through 110 percent, then pauses", () => { + expect(getCapacityState("free", 79)).toMatchObject({ state: "ok", accepts: true }); + expect(getCapacityState("free", 80)).toMatchObject({ state: "warning", accepts: true }); + expect(getCapacityState("free", 100)).toMatchObject({ state: "grace", accepts: true }); + expect(getCapacityState("free", 109)).toMatchObject({ state: "grace", accepts: true }); + expect(getCapacityState("free", 110)).toMatchObject({ state: "paused", accepts: false }); + }); +}); diff --git a/__tests__/forms-db.integration.test.ts b/__tests__/forms-db.integration.test.ts new file mode 100644 index 0000000..1ea3537 --- /dev/null +++ b/__tests__/forms-db.integration.test.ts @@ -0,0 +1,97 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Pool } from "pg"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { eq, sql } from "drizzle-orm"; +import { endpoints, forms, usagePeriods, users } from "../lib/db/schema"; +import { FORM_STARTERS } from "../lib/forms/starters"; +import { compileEndpointSchema } from "../lib/forms/definition"; + +const databaseUrl = process.env.TEST_DATABASE_URL; +const suite = databaseUrl ? describe : describe.skip; + +suite("Forms PostgreSQL integration", () => { + const pool = new Pool({ connectionString: databaseUrl }); + const database = drizzle(pool); + const userId = `test-${randomUUID()}`; + let endpointId = ""; + let formId = ""; + + beforeAll(async () => { + await database.insert(users).values({ id: userId, email: `${userId}@example.com` }); + const [endpoint] = await database + .insert(endpoints) + .values({ + userId, + name: "Integration endpoint", + schema: [], + token: "test-token", + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ id: endpoints.id }); + endpointId = endpoint.id; + const [form] = await database + .insert(forms) + .values({ + userId, + endpointId, + name: "Integration form", + draftDefinition: FORM_STARTERS.contact, + }) + .returning({ id: forms.id }); + formId = form.id; + }); + + afterAll(async () => { + await database.delete(users).where(eq(users.id, userId)); + await pool.end(); + }); + + it("publishes the endpoint schema and immutable snapshot in one transaction", async () => { + await database.transaction(async (tx) => { + await tx + .update(endpoints) + .set({ schema: compileEndpointSchema(FORM_STARTERS.contact) }) + .where(eq(endpoints.id, endpointId)); + await tx + .update(forms) + .set({ + publishedDefinition: FORM_STARTERS.contact, + publishedRevision: sql`${forms.publishedRevision} + 1`, + publishedAt: new Date(), + }) + .where(eq(forms.id, formId)); + }); + + const [published] = await database.select().from(forms).where(eq(forms.id, formId)); + const [endpoint] = await database.select().from(endpoints).where(eq(endpoints.id, endpointId)); + expect(published.publishedRevision).toBe(1); + expect(published.publishedDefinition).toEqual(FORM_STARTERS.contact); + expect(endpoint.schema).toEqual(compileEndpointSchema(FORM_STARTERS.contact)); + }); + + it("blocks endpoint deletion while its form exists", async () => { + await expect(database.delete(endpoints).where(eq(endpoints.id, endpointId))).rejects.toThrow(); + }); + + it("increments a UTC month counter atomically under concurrency", async () => { + const periodStart = "2026-09-01"; + await Promise.all( + Array.from({ length: 20 }, () => + database + .insert(usagePeriods) + .values({ userId, periodStart, leadCount: 1 }) + .onConflictDoUpdate({ + target: [usagePeriods.userId, usagePeriods.periodStart], + set: { leadCount: sql`${usagePeriods.leadCount} + 1` }, + }) + ) + ); + const [usage] = await database + .select() + .from(usagePeriods) + .where(eq(usagePeriods.userId, userId)); + expect(usage.leadCount).toBe(20); + }); +}); diff --git a/__tests__/forms-definition.test.ts b/__tests__/forms-definition.test.ts new file mode 100644 index 0000000..7104a41 --- /dev/null +++ b/__tests__/forms-definition.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { + compileEndpointSchema, + formDefinitionV1Schema, + validateFormValues, +} from "../lib/forms/definition"; + +const contactForm = { + version: 1 as const, + title: "Contact us", + description: "We usually reply within one business day.", + fields: [ + { + id: "fld_name", + key: "name", + kind: "text" as const, + label: "Name", + required: true, + validation: { minLength: 2, maxLength: 80 }, + }, + { + id: "fld_email", + key: "email", + kind: "email" as const, + label: "Email", + required: true, + }, + { + id: "fld_topics", + key: "topics", + kind: "checkbox-group" as const, + label: "Topics", + required: false, + options: [ + { id: "opt_sales", label: "Sales", value: "sales" }, + { id: "opt_support", label: "Support", value: "support" }, + ], + validation: { minSelections: 1, maxSelections: 2 }, + }, + ], + submitLabel: "Send", + completion: { type: "message" as const, message: "Thanks — we’ll be in touch." }, +}; + +describe("FormDefinitionV1", () => { + it("parses a complete versioned definition", () => { + expect(formDefinitionV1Schema.parse(contactForm)).toEqual(contactForm); + }); + + it("rejects duplicate stable field ids and submission keys", () => { + const duplicate = { + ...contactForm, + fields: [contactForm.fields[0], { ...contactForm.fields[1], id: "fld_name", key: "name" }], + }; + + const result = formDefinitionV1Schema.safeParse(duplicate); + expect(result.success).toBe(false); + expect(result.error?.issues.map((issue) => issue.message)).toEqual( + expect.arrayContaining(["Field IDs must be unique.", "Submission keys must be unique."]) + ); + }); + + it("only allows validated HTTPS completion redirects", () => { + const result = formDefinitionV1Schema.safeParse({ + ...contactForm, + completion: { type: "redirect", url: "http://example.com/thanks" }, + }); + + expect(result.success).toBe(false); + }); + + it("compiles fields into Router's endpoint schema", () => { + expect(compileEndpointSchema(contactForm)).toEqual([ + { + key: "name", + value: "string", + required: true, + constraints: { minLength: 2, maxLength: 80 }, + }, + { key: "email", value: "email", required: true }, + { + key: "topics", + value: "string_array", + required: false, + constraints: { + allowedValues: ["sales", "support"], + minItems: 1, + maxItems: 2, + }, + }, + ]); + }); +}); + +describe("validateFormValues", () => { + it("normalizes valid values without leaking unknown fields", () => { + const result = validateFormValues(contactForm, { + name: " Ada Lovelace ", + email: "ada@example.com", + topics: ["sales"], + }); + + expect(result).toEqual({ + success: true, + data: { + name: "Ada Lovelace", + email: "ada@example.com", + topics: ["sales"], + }, + }); + }); + + it("returns structured field errors and rejects unknown fields", () => { + const result = validateFormValues(contactForm, { + name: "A", + email: "not-an-email", + topics: ["not-an-option"], + endpointToken: "must-not-pass-through", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors).toMatchObject({ + name: expect.any(Array), + email: expect.any(Array), + topics: expect.any(Array), + endpointToken: ["Unknown field."], + }); + } + }); +}); diff --git a/__tests__/forms-security.test.ts b/__tests__/forms-security.test.ts new file mode 100644 index 0000000..73a3258 --- /dev/null +++ b/__tests__/forms-security.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createSubmissionToken, + verifySubmissionToken, +} from "../lib/forms/submission-token"; +import { normalizeOrigin } from "../lib/forms/origins"; + +describe("normalizeOrigin", () => { + it("normalizes a site URL to a stable origin", () => { + expect(normalizeOrigin("https://Example.COM:443/contact?from=router#form")).toBe( + "https://example.com" + ); + expect(normalizeOrigin("https://example.com:8443/path")).toBe( + "https://example.com:8443" + ); + }); + + it("allows local HTTP development but rejects insecure public origins", () => { + expect(normalizeOrigin("http://localhost:3000/test")).toBe( + "http://localhost:3000" + ); + expect(() => normalizeOrigin("http://example.com")).toThrow("HTTPS"); + expect(() => normalizeOrigin("https://*.example.com")).toThrow(); + }); +}); + +describe("signed form submission tokens", () => { + const secret = "test-secret-with-enough-entropy-for-unit-tests"; + + it("round-trips the exact form, placement, and normalized origin", () => { + const now = new Date("2026-09-01T18:00:00.000Z"); + const token = createSubmissionToken( + { + publicId: "form_public_1", + placement: "embed", + origin: "https://example.com", + }, + { secret, now } + ); + + expect(verifySubmissionToken(token, { secret, now })).toMatchObject({ + publicId: "form_public_1", + placement: "embed", + origin: "https://example.com", + expiresAt: "2026-09-01T19:00:00.000Z", + }); + }); + + it("rejects tampering and expiry", () => { + const now = new Date("2026-09-01T18:00:00.000Z"); + const token = createSubmissionToken( + { publicId: "form_public_1", placement: "hosted" }, + { secret, now } + ); + + expect(() => verifySubmissionToken(`${token}x`, { secret, now })).toThrow( + "Invalid submission token" + ); + + vi.setSystemTime(new Date("2026-09-01T19:00:01.000Z")); + expect(() => + verifySubmissionToken(token, { + secret, + now: new Date("2026-09-01T19:00:01.000Z"), + }) + ).toThrow("expired"); + vi.useRealTimers(); + }); +}); diff --git a/__tests__/stripe-subscription-state.test.ts b/__tests__/stripe-subscription-state.test.ts new file mode 100644 index 0000000..4c13472 --- /dev/null +++ b/__tests__/stripe-subscription-state.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + endedSubscriptionState, + failedPaymentState, + subscriptionEntitlementState, +} from "../lib/forms/stripe-subscription-state"; + +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +function subscription(priceId: string, cancelAtPeriodEnd = false) { + return { + priceId, + customerId: "cus_router", + subscriptionId: "sub_router", + status: "active", + currentPeriodEnd: 1_800_000_000, + cancelAtPeriodEnd, + }; +} + +describe("Stripe entitlement transitions", () => { + it("recognizes a new checkout or resubscription price", () => { + process.env.STRIPE_PRO_MONTHLY_PRICE_ID = "price_new_pro"; + expect(subscriptionEntitlementState(subscription("price_new_pro"))).toMatchObject({ + plan: "pro", + legacyPriceMigrationRequired: false, + stripeSubscriptionStatus: "active", + }); + }); + + it("preserves a legacy entitlement and its confirmed period-end cancellation", () => { + expect( + subscriptionEntitlementState( + subscription("price_1QVIiNCr7fYvZ7eq3SRX0YGS", true) + ) + ).toMatchObject({ + plan: "lite", + legacyPriceMigrationRequired: true, + stripeCancelAtPeriodEnd: true, + }); + }); + + it("downgrades to Free when a subscription ends", () => { + expect(endedSubscriptionState("canceled")).toEqual({ + plan: "free", + stripeSubscriptionId: null, + stripeSubscriptionStatus: "canceled", + stripeCurrentPeriodEnd: null, + stripeCancelAtPeriodEnd: false, + legacyPriceMigrationRequired: false, + }); + }); + + it("marks failed payments without immediately changing the plan", () => { + expect(failedPaymentState()).toEqual({ stripeSubscriptionStatus: "past_due" }); + }); + + it("rejects unrecognized prices", () => { + expect(() => subscriptionEntitlementState(subscription("price_unknown"))).toThrow( + "Unrecognized Stripe price" + ); + }); +}); diff --git a/__tests__/usage-notifications.test.ts b/__tests__/usage-notifications.test.ts new file mode 100644 index 0000000..c83135f --- /dev/null +++ b/__tests__/usage-notifications.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { crossedUsageThresholds } from "../lib/forms/usage-notifications"; + +describe("usage notification thresholds", () => { + it("claims no notification below 80 percent", () => { + expect(crossedUsageThresholds({ used: 79, limit: 100 })).toEqual([]); + }); + + it("claims the 80 percent notification at the rounded-up boundary", () => { + expect(crossedUsageThresholds({ used: 81, limit: 101 })).toEqual([80]); + }); + + it("claims both thresholds when usage is already at the allowance", () => { + expect(crossedUsageThresholds({ used: 100, limit: 100 })).toEqual([80, 100]); + }); + + it("does not notify enterprise accounts with contract-defined capacity", () => { + expect(crossedUsageThresholds({ used: 1_000_000, limit: null })).toEqual([]); + }); +}); diff --git a/__tests__/wordpress-token.test.ts b/__tests__/wordpress-token.test.ts new file mode 100644 index 0000000..1087df5 --- /dev/null +++ b/__tests__/wordpress-token.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + createWordPressToken, + hashWordPressToken, + tokenPrefix, + verifyWordPressToken, +} from "../lib/forms/wordpress-token"; + +describe("WordPress site tokens", () => { + it("generates an identifiable high-entropy token and stores only its hash", () => { + const token = createWordPressToken(); + expect(token).toMatch(/^rtr_wp_[A-Za-z0-9_-]{8}_[A-Za-z0-9_-]{32,}$/); + expect(tokenPrefix(token)).toHaveLength(8); + expect(hashWordPressToken(token)).not.toContain(token); + }); + + it("compares token hashes without accepting a modified token", () => { + const token = createWordPressToken(); + const hash = hashWordPressToken(token); + expect(verifyWordPressToken(token, hash)).toBe(true); + expect(verifyWordPressToken(`${token}x`, hash)).toBe(false); + }); +}); diff --git a/app/api/cron/route.ts b/app/api/cron/route.ts index ad90060..93cbfa5 100644 --- a/app/api/cron/route.ts +++ b/app/api/cron/route.ts @@ -1,5 +1,6 @@ import type { NextRequest } from "next/server"; import { clearLeadCount } from "@/lib/data/users"; +import { pruneFormRateBuckets } from "@/lib/forms/rate-limit"; /** * Cron job to clear lead count run through Vercel @@ -18,6 +19,7 @@ export async function GET(request: NextRequest) { } await clearLeadCount(); + const prunedRateBuckets = await pruneFormRateBuckets(); - return Response.json({ success: true }); + return Response.json({ success: true, prunedRateBuckets }); } diff --git a/app/api/endpoints/[id]/route.ts b/app/api/endpoints/[id]/route.ts index f84888b..f5bee3d 100644 --- a/app/api/endpoints/[id]/route.ts +++ b/app/api/endpoints/[id]/route.ts @@ -1,315 +1,127 @@ import { NextResponse } from "next/server"; -import { - convertToCorrectTypes, - generateDynamicSchema, - validateAndParseData, -} from "@/lib/validation"; -import { headers } from "next/headers"; -import { createLead } from "@/lib/data/leads"; -import { createLog } from "@/lib/data/logs"; -import { getErrorMessage } from "@/lib/helpers/error-message"; import { constructBodyFromURLParameters } from "@/lib/helpers/construct-body"; +import { convertToCorrectTypes } from "@/lib/validation"; import { getPostingEndpointById } from "@/lib/data/endpoints"; import { - incrementLeadCount, - getUserPlan, - getLeadCount, -} from "@/lib/data/users"; + acceptLead, + LeadCapacityError, + LeadEndpointError, + LeadValidationError, +} from "@/lib/forms/lead-acceptance"; + +const MAX_BODY_BYTES = 64 * 1024; + +function errorResponse(error: unknown): NextResponse { + if (error instanceof LeadValidationError) { + return NextResponse.json( + { error: "validation_failed", fields: error.fieldErrors }, + { status: 400 } + ); + } + if (error instanceof LeadCapacityError) { + return NextResponse.json( + { error: "monthly_capacity_reached", capacity: error.capacity }, + { status: 429 } + ); + } + if (error instanceof LeadEndpointError) { + return NextResponse.json( + { + error: error.status === 404 ? "not_found" : "endpoint_disabled", + message: error.message, + }, + { status: error.status } + ); + } + console.error(error); + return NextResponse.json({ error: "internal_error" }, { status: 500 }); +} + +async function readJsonBody(request: Request): Promise { + const declaredLength = Number(request.headers.get("content-length") ?? 0); + if (declaredLength > MAX_BODY_BYTES) { + throw new Response("Payload too large", { status: 413 }); + } + const body = await request.text(); + if (Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) { + throw new Response("Payload too large", { status: 413 }); + } + return JSON.parse(body); +} -/** - * API route for posting a lead using POST - */ +/** Legacy bearer-token endpoint. Its URL and authentication contract are unchanged. */ export async function POST( request: Request, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + const authorization = request.headers.get("authorization"); + if (!authorization?.startsWith("Bearer ")) { + return NextResponse.json( + { message: "Unauthorized. No valid bearer token provided." }, + { status: 401 } + ); + } - try { - const headersList = await headers(); - const authorization = headersList.get("authorization"); - - if (!authorization || !authorization.startsWith("Bearer ")) { - return NextResponse.json( - { message: "Unauthorized. No valid bearer token provided." }, - { status: 401 } - ); - } - - const token = authorization.split(" ")[1]; - const data = await request.json(); - const endpoint = await getPostingEndpointById(id); - - if (!endpoint) - return NextResponse.json( - { message: "Endpoint not found." }, - { status: 404 } - ); - - if (endpoint.token !== token) { - return NextResponse.json( - { message: "Unauthorized. Invalid token provided." }, - { status: 401 } - ); - } - - if (!endpoint.enabled) { - return NextResponse.json( - { message: "Endpoint is disabled." }, - { status: 403 } - ); - } - - const plan = await getUserPlan(id); - const leadCount = await getLeadCount(id); - - let leadLimit: number; - switch (plan) { - case "free": - leadLimit = 100; - break; - case "lite": - leadLimit = 1000; - break; - case "pro": - leadLimit = 10000; - break; - case "business": - leadLimit = 50000; - break; - case "enterprise": - leadLimit = 999999; - break; - default: - leadLimit = 100; // Fallback to free tier limit - } - - if (leadCount >= leadLimit) { - return NextResponse.json( - { message: "Lead limit reached." }, - { status: 429 } - ); - } - - const schema = endpoint?.schema as GeneralSchema[]; - const dynamicSchema = generateDynamicSchema(schema); - const parsedData = validateAndParseData(dynamicSchema, data); - - if (!parsedData.success) { - createLog( - "error", - "http", - JSON.stringify(parsedData.error.format()), - endpoint.id - ); - - return NextResponse.json( - { errors: parsedData.error.format() }, - { status: 400 } - ); - } - - const leadId = await createLead(endpoint.id, parsedData.data); - - await createLog("success", "http", leadId, endpoint.id); - await incrementLeadCount(id); - - // webhook posting -- eventually make this a background job - if (endpoint.webhookEnabled && endpoint.webhook) { - // Only wait 3 second(s) for a response - const webhookController = new AbortController(); - const webhookTimeoutPromise = new Promise((_, reject) => { - setTimeout(async () => { - // create a log of the timeout error - await createLog("error", "webhook", "Webhook timed out.", id); - webhookController.abort(); - reject(new Error("Request timed out")); - }, 3000); - }); - const webhookFetchPromise: Promise = fetch(endpoint.webhook, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(parsedData.data), - signal: webhookController.signal, - }); - const webhookResponse = await Promise.race([ - webhookFetchPromise, - webhookTimeoutPromise, - ]); + const endpoint = await getPostingEndpointById(id); + if (!endpoint) { + return NextResponse.json({ message: "Endpoint not found." }, { status: 404 }); + } + if (endpoint.token !== authorization.slice("Bearer ".length)) { + return NextResponse.json( + { message: "Unauthorized. Invalid token provided." }, + { status: 401 } + ); + } - if (!webhookResponse.ok) { - const contentType = webhookResponse.headers.get("Content-Type"); - let errorData; - if (contentType && contentType.includes("application/json")) { - errorData = await webhookResponse.json(); - } else if (contentType && contentType.includes("text")) { - errorData = await webhookResponse.text(); - } else { - errorData = "Received non-text response"; - } - await createLog("error", "webhook", errorData, id); - } else { - createLog( - "success", - "webhook", - `${endpoint.webhook} -> Webhook successful`, - id - ); - } + try { + const values = await readJsonBody(request); + const result = await acceptLead({ + endpointId: id, + values, + placement: "headless", + }); + return NextResponse.json({ success: true, id: result.leadId }); + } catch (error) { + if (error instanceof Response) return error; + if (error instanceof SyntaxError) { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); } - - return NextResponse.json({ success: true, id: leadId }); - } catch (error: unknown) { - await createLog("error", "http", getErrorMessage(error), id); - - console.error(error); - - return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + return errorResponse(error); } } -/** - * API route for posting a lead using GET - * - * Only used when the user is posting via HTML form element - */ +/** Compatibility route for existing native HTML forms. */ export async function GET( request: Request, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + const endpoint = await getPostingEndpointById(id); + if (!endpoint) { + return NextResponse.json({ message: "Endpoint not found." }, { status: 404 }); + } - try { - const headersList = await headers(); - const referer = headersList.get("referer"); - const { searchParams } = new URL(request.url); - - const endpoint = await getPostingEndpointById(id); - - if (!endpoint) { - return NextResponse.json( - { message: "Endpoint not found." }, - { status: 404 } - ); - } - - if (!endpoint.enabled) { - return NextResponse.json( - { message: "Endpoint is disabled." }, - { status: 403 } - ); - } - - const plan = await getUserPlan(id); - const leadCount = await getLeadCount(id); - - let leadLimit: number; - switch (plan) { - case "free": - leadLimit = 100; - break; - case "lite": - leadLimit = 1000; - break; - case "pro": - leadLimit = 10000; - break; - case "business": - leadLimit = 50000; - break; - case "enterprise": - leadLimit = 999999; - break; - default: - leadLimit = 100; // Fallback to free tier limit - } - - if (leadCount >= leadLimit) { - return NextResponse.json( - { message: "Lead limit reached." }, - { status: 429 } - ); - } - - const rawData = constructBodyFromURLParameters(searchParams); - const schema = endpoint?.schema as GeneralSchema[]; - const data = convertToCorrectTypes(rawData, schema); - const dynamicSchema = generateDynamicSchema(schema); - const parsedData = validateAndParseData(dynamicSchema, data); - - if (!parsedData.success) { - createLog( - "error", - "http", - JSON.stringify(parsedData.error.format()), - endpoint.id - ); + const referer = request.headers.get("referer"); + const rawValues = constructBodyFromURLParameters( + new URL(request.url).searchParams + ); + const values = convertToCorrectTypes( + rawValues, + endpoint.schema as GeneralSchema[] + ); + try { + await acceptLead({ endpointId: id, values, placement: "legacy_html" }); + return NextResponse.redirect( + new URL(endpoint.successUrl || referer || "/success", request.url) + ); + } catch (error) { + if (error instanceof LeadValidationError) { return NextResponse.redirect( - new URL(endpoint?.failUrl || referer || "/fail") + new URL(endpoint.failUrl || referer || "/fail", request.url) ); } - - const leadId = await createLead(endpoint.id, parsedData.data); - - await createLog("success", "http", leadId, endpoint.id); - await incrementLeadCount(id); - - // webhook posting -- eventually make this a background job - if (endpoint.webhookEnabled && endpoint.webhook) { - // Only wait 3 second(s) for a response - const webhookController = new AbortController(); - const webhookTimeoutPromise = new Promise((_, reject) => { - setTimeout(async () => { - await createLog("error", "webhook", "Webhook timed out.", id); - webhookController.abort(); - reject(new Error("Request timed out")); - }, 3000); - }); - const webhookFetchPromise: Promise = fetch(endpoint.webhook, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(parsedData.data), - signal: webhookController.signal, - }); - const webhookResponse = await Promise.race([ - webhookFetchPromise, - webhookTimeoutPromise, - ]); - - if (!webhookResponse.ok) { - const contentType = webhookResponse.headers.get("Content-Type"); - let errorData; - if (contentType && contentType.includes("application/json")) { - errorData = await webhookResponse.json(); - } else if (contentType && contentType.includes("text")) { - errorData = await webhookResponse.text(); - } else { - errorData = "Received non-text response"; - } - await createLog("error", "webhook", errorData, id); - } else { - createLog( - "success", - "webhook", - `${endpoint.webhook} -> Webhook successful`, - id - ); - } - } - - return NextResponse.redirect( - new URL(endpoint?.successUrl || referer || "/success") - ); - } catch (error: unknown) { - await createLog("error", "http", getErrorMessage(error), id); - - console.error(error); - - return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + return errorResponse(error); } } diff --git a/app/api/integrations/wordpress/forms/route.ts b/app/api/integrations/wordpress/forms/route.ts new file mode 100644 index 0000000..fcd5654 --- /dev/null +++ b/app/api/integrations/wordpress/forms/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { listPublishedFormsForWordPressToken } from "@/lib/data/wordpress"; + +export async function GET(request: Request) { + const authorization = request.headers.get("authorization"); + if (!authorization?.startsWith("Bearer ")) { + return NextResponse.json({ error: "missing_site_token" }, { status: 401 }); + } + + const forms = await listPublishedFormsForWordPressToken( + authorization.slice("Bearer ".length) + ); + if (!forms) { + return NextResponse.json({ error: "invalid_or_revoked_site_token" }, { status: 401 }); + } + + return NextResponse.json( + { + forms: forms.map((form) => ({ + publicId: form.publicId, + name: form.name, + title: form.title?.title ?? form.name, + revision: form.revision, + })), + }, + { headers: { "Cache-Control": "private, no-store" } } + ); +} diff --git a/app/api/public/forms/[publicId]/leads/route.ts b/app/api/public/forms/[publicId]/leads/route.ts new file mode 100644 index 0000000..16d6295 --- /dev/null +++ b/app/api/public/forms/[publicId]/leads/route.ts @@ -0,0 +1,155 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getPublishedForm } from "@/lib/data/forms"; +import { + acceptLead, + LeadCapacityError, + LeadEndpointError, + LeadValidationError, +} from "@/lib/forms/lead-acceptance"; +import { requestOrigin } from "@/lib/forms/origins"; +import { isApprovedFormOrigin, publicCorsHeaders } from "@/lib/forms/public-access"; +import { enforceFormRateLimit, FormRateLimitError } from "@/lib/forms/rate-limit"; +import { verifySubmissionToken } from "@/lib/forms/submission-token"; +import { publicFormsEnabled } from "@/lib/forms/feature-flags"; + +const MAX_BODY_BYTES = 64 * 1024; +const inputSchema = z.object({ + values: z.record(z.unknown()), + submitToken: z.string().min(1), + website: z.string().max(500).optional(), +}); + +function clientIp(request: Request): string { + return ( + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown" + ); +} + +async function readBody(request: Request): Promise { + const declaredLength = Number(request.headers.get("content-length") ?? 0); + if (declaredLength > MAX_BODY_BYTES) throw new Error("payload_too_large"); + const text = await request.text(); + if (Buffer.byteLength(text, "utf8") > MAX_BODY_BYTES) { + throw new Error("payload_too_large"); + } + return JSON.parse(text); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ publicId: string }> } +) { + if (!publicFormsEnabled()) { + return NextResponse.json({ error: "form_not_found" }, { status: 404 }); + } + const { publicId } = await params; + const origin = requestOrigin(request); + let parsed: z.infer; + try { + parsed = inputSchema.parse(await readBody(request)); + } catch (error) { + const status = error instanceof Error && error.message === "payload_too_large" ? 413 : 400; + return NextResponse.json({ error: status === 413 ? "payload_too_large" : "invalid_request" }, { status }); + } + + let token; + try { + token = verifySubmissionToken(parsed.submitToken); + } catch (error) { + return NextResponse.json( + { error: "invalid_submit_token", message: error instanceof Error ? error.message : undefined }, + { status: 401 } + ); + } + + if (token.publicId !== publicId || (token.origin && token.origin !== origin)) { + return NextResponse.json({ error: "invalid_submit_token" }, { status: 401 }); + } + if (token.placement !== "hosted") { + if (!origin) return NextResponse.json({ error: "origin_not_approved" }, { status: 403 }); + const approved = await isApprovedFormOrigin({ + publicId, + origin, + placement: token.placement, + }); + if (!approved) return NextResponse.json({ error: "origin_not_approved" }, { status: 403 }); + } + + const form = await getPublishedForm(publicId); + if (!form) return NextResponse.json({ error: "form_not_found" }, { status: 404 }); + const corsHeaders = publicCorsHeaders(origin, Boolean(origin)); + + // Honeypot submissions receive a neutral success but never create a lead. + // Validate the signed session and origin first so cross-origin clients still + // receive the same CORS boundary as a real submission. + if (parsed.website) { + return NextResponse.json( + { + leadId: "accepted", + completion: { type: "message", message: "Thanks." }, + }, + { headers: corsHeaders } + ); + } + + try { + await enforceFormRateLimit({ formId: form.id, ip: clientIp(request) }); + const result = await acceptLead({ + publicId, + values: parsed.values, + placement: token.placement, + }); + return NextResponse.json( + { leadId: result.leadId, completion: result.completion }, + { headers: corsHeaders } + ); + } catch (error) { + if (error instanceof LeadValidationError) { + return NextResponse.json( + { error: "validation_failed", fields: error.fieldErrors }, + { status: 400, headers: corsHeaders } + ); + } + if (error instanceof LeadCapacityError) { + return NextResponse.json( + { error: "monthly_capacity_reached", capacity: error.capacity }, + { status: 429, headers: corsHeaders } + ); + } + if (error instanceof FormRateLimitError) { + corsHeaders.set("Retry-After", String(error.retryAfter)); + return NextResponse.json( + { error: "rate_limited", retryAfter: error.retryAfter }, + { status: 429, headers: corsHeaders } + ); + } + if (error instanceof LeadEndpointError) { + return NextResponse.json( + { error: error.status === 404 ? "form_not_found" : "form_disabled" }, + { status: error.status, headers: corsHeaders } + ); + } + console.error(error); + return NextResponse.json({ error: "internal_error" }, { status: 500, headers: corsHeaders }); + } +} + +export async function OPTIONS( + request: Request, + { params }: { params: Promise<{ publicId: string }> } +) { + if (!publicFormsEnabled()) return new NextResponse(null, { status: 404 }); + const { publicId } = await params; + const origin = requestOrigin(request); + const approved = origin + ? (await isApprovedFormOrigin({ publicId, origin, placement: "embed" })) || + (await isApprovedFormOrigin({ publicId, origin, placement: "wordpress" })) + : false; + return new NextResponse(null, { + status: approved ? 204 : 403, + headers: publicCorsHeaders(origin, approved), + }); +} diff --git a/app/api/public/forms/[publicId]/render-session/route.ts b/app/api/public/forms/[publicId]/render-session/route.ts new file mode 100644 index 0000000..9532f86 --- /dev/null +++ b/app/api/public/forms/[publicId]/render-session/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getPublishedForm } from "@/lib/data/forms"; +import { requestOrigin } from "@/lib/forms/origins"; +import { isApprovedFormOrigin, publicCorsHeaders } from "@/lib/forms/public-access"; +import { createSubmissionToken } from "@/lib/forms/submission-token"; +import { publicFormsEnabled } from "@/lib/forms/feature-flags"; + +const inputSchema = z.object({ + placement: z.enum(["hosted", "embed", "wordpress"]), +}); + +function isHostedRequest(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return hostname === "forms.router.so" || hostname === "localhost" || hostname === "127.0.0.1"; +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ publicId: string }> } +) { + if (!publicFormsEnabled()) { + return NextResponse.json({ error: "form_not_found" }, { status: 404 }); + } + const { publicId } = await params; + const input = inputSchema.safeParse(await request.json().catch(() => null)); + if (!input.success) { + return NextResponse.json({ error: "invalid_placement" }, { status: 400 }); + } + + const form = await getPublishedForm(publicId); + if (!form) return NextResponse.json({ error: "form_not_found" }, { status: 404 }); + + const origin = requestOrigin(request); + let approved = false; + if (input.data.placement === "hosted") { + const targetOrigin = new URL(request.url).origin.toLowerCase(); + approved = isHostedRequest(request) && (!origin || origin === targetOrigin); + } else if (origin) { + approved = await isApprovedFormOrigin({ + publicId, + origin, + placement: input.data.placement, + }); + } + + if (!approved) { + return NextResponse.json( + { error: "origin_not_approved" }, + { status: 403, headers: publicCorsHeaders(origin, false) } + ); + } + + const headers = publicCorsHeaders(origin, Boolean(origin)); + headers.set("Cache-Control", "no-store"); + return NextResponse.json( + { + submitToken: createSubmissionToken({ + publicId, + placement: input.data.placement, + ...(origin ? { origin } : {}), + }), + expiresIn: 3600, + }, + { headers } + ); +} + +export async function OPTIONS( + request: Request, + { params }: { params: Promise<{ publicId: string }> } +) { + if (!publicFormsEnabled()) return new NextResponse(null, { status: 404 }); + const { publicId } = await params; + const origin = requestOrigin(request); + const approved = origin + ? (await isApprovedFormOrigin({ publicId, origin, placement: "embed" })) || + (await isApprovedFormOrigin({ publicId, origin, placement: "wordpress" })) + : false; + return new NextResponse(null, { + status: approved ? 204 : 403, + headers: publicCorsHeaders(origin, approved), + }); +} diff --git a/app/api/public/forms/[publicId]/route.ts b/app/api/public/forms/[publicId]/route.ts new file mode 100644 index 0000000..cd16edb --- /dev/null +++ b/app/api/public/forms/[publicId]/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { getPublishedForm } from "@/lib/data/forms"; +import { isApprovedFormOrigin, publicCorsHeaders } from "@/lib/forms/public-access"; +import { requestOrigin } from "@/lib/forms/origins"; +import { publicFormsEnabled } from "@/lib/forms/feature-flags"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ publicId: string }> } +) { + if (!publicFormsEnabled()) { + return NextResponse.json({ error: "form_not_found" }, { status: 404 }); + } + const { publicId } = await params; + const published = await getPublishedForm(publicId); + if (!published) { + return NextResponse.json( + { error: "form_not_found" }, + { status: 404, headers: { "Cache-Control": "no-store" } } + ); + } + + const origin = requestOrigin(request); + const approved = origin + ? (await isApprovedFormOrigin({ publicId, origin, placement: "embed" })) || + (await isApprovedFormOrigin({ publicId, origin, placement: "wordpress" })) + : false; + const headers = publicCorsHeaders(origin, approved); + const etag = `W/\"${published.publicId}-${published.revision}\"`; + headers.set("ETag", etag); + headers.set( + "Cache-Control", + "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400" + ); + + if (request.headers.get("if-none-match") === etag) { + return new NextResponse(null, { status: 304, headers }); + } + + return NextResponse.json( + { + publicId: published.publicId, + revision: published.revision, + definition: published.definition, + attribution: published.showAttribution + ? { visible: true, label: "Powered by Router", href: "https://router.so" } + : { visible: false }, + }, + { headers } + ); +} + +export async function OPTIONS( + request: Request, + { params }: { params: Promise<{ publicId: string }> } +) { + if (!publicFormsEnabled()) return new NextResponse(null, { status: 404 }); + const { publicId } = await params; + const origin = requestOrigin(request); + const approved = origin + ? (await isApprovedFormOrigin({ publicId, origin, placement: "embed" })) || + (await isApprovedFormOrigin({ publicId, origin, placement: "wordpress" })) + : false; + return new NextResponse(null, { + status: approved ? 204 : 403, + headers: publicCorsHeaders(origin, approved), + }); +} diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index a4fd014..954783b 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -1,152 +1,140 @@ import { headers } from "next/headers"; import { NextResponse } from "next/server"; -import { Stripe } from "stripe"; -import { db } from "@/lib/db"; -import { users } from "@/lib/db/schema"; +import type Stripe from "stripe"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -import { STRIPE_PLANS } from "@/lib/constants/stripe"; - -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); -const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { + planForNewPrice, +} from "@/lib/constants/stripe"; +import { getStripe } from "@/lib/utils/stripe-client"; +import { getUserPublishedFormIds } from "@/lib/data/forms"; +import { invalidatePublishedForm } from "@/lib/forms/cache"; +import { + endedSubscriptionState, + failedPaymentState, + subscriptionEntitlementState, +} from "@/lib/forms/stripe-subscription-state"; + +async function updateSubscription(subscription: Stripe.Subscription) { + const priceId = subscription.items.data[0]?.price.id; + if (!priceId) throw new Error("Subscription has no price."); + const state = subscriptionEntitlementState({ + priceId, + customerId: subscription.customer as string, + subscriptionId: subscription.id, + status: subscription.status, + currentPeriodEnd: subscription.current_period_end, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + }); + + const userCondition = subscription.metadata.routerUserId + ? eq(users.id, subscription.metadata.routerUserId) + : eq(users.stripeCustomerId, subscription.customer as string); + const [updated] = await db + .update(users) + .set(state) + .where(userCondition) + .returning({ id: users.id }); + + if (updated) { + const publicIds = await getUserPublishedFormIds(updated.id); + publicIds.forEach(invalidatePublishedForm); + } +} export async function POST(request: Request) { try { - const body = await request.text(); - const signature = (await headers()).get("stripe-signature")!; - - // Verify the webhook signature + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + const signature = (await headers()).get("stripe-signature"); + if (!webhookSecret || !signature) { + return NextResponse.json( + { error: "Stripe webhook is not configured." }, + { status: 503 } + ); + } + const stripe = getStripe(); const event = stripe.webhooks.constructEvent( - body, + await request.text(), signature, webhookSecret ); - // Handle checkout session completion if (event.type === "checkout.session.completed") { - const session = event.data.object as Stripe.Checkout.Session; - - // Get the price ID from the session - const lineItems = await stripe.checkout.sessions.listLineItems( - session.id - ); - const priceId = lineItems.data[0].price?.id; - - // Determine the plan based on price ID - let plan: "lite" | "pro" | "business"; - - // Get all possible price IDs for each plan - const priceIdToPlan = { - [STRIPE_PLANS.lite.monthlyPriceId.dev]: "lite", - [STRIPE_PLANS.lite.monthlyPriceId.prod]: "lite", - [STRIPE_PLANS.lite.yearlyPriceId.dev]: "lite", - [STRIPE_PLANS.lite.yearlyPriceId.prod]: "lite", - [STRIPE_PLANS.pro.monthlyPriceId.dev]: "pro", - [STRIPE_PLANS.pro.monthlyPriceId.prod]: "pro", - [STRIPE_PLANS.pro.yearlyPriceId.dev]: "pro", - [STRIPE_PLANS.pro.yearlyPriceId.prod]: "pro", - [STRIPE_PLANS.business.monthlyPriceId.dev]: "business", - [STRIPE_PLANS.business.monthlyPriceId.prod]: "business", - [STRIPE_PLANS.business.yearlyPriceId.dev]: "business", - [STRIPE_PLANS.business.yearlyPriceId.prod]: "business", - } as const; - - plan = priceIdToPlan[priceId as keyof typeof priceIdToPlan]; - - if (!plan) { - console.error(`Invalid price ID: ${priceId}`); - throw new Error(`Invalid price ID: ${priceId}`); - } - - const customerEmail = session.customer_email; - if (!customerEmail) { - throw new Error("No customer email found in session"); - } - - await db + const session = event.data.object; + const lineItems = await stripe.checkout.sessions.listLineItems(session.id); + const priceId = lineItems.data[0]?.price?.id; + const plan = priceId ? planForNewPrice(priceId) : null; + if (!plan) throw new Error("Checkout used an unrecognized or retired price."); + if (!session.customer_details?.email) throw new Error("Checkout has no customer email."); + const [updated] = await db .update(users) .set({ plan, stripeCustomerId: session.customer as string, + stripeSubscriptionId: session.subscription as string, + legacyPriceMigrationRequired: false, }) - .where(eq(users.email, customerEmail)); - } - - // Handle subscription updates - if (event.type === "customer.subscription.updated") { - const subscription = event.data.object as Stripe.Subscription; - const priceId = subscription.items.data[0].price.id; - - // Determine the new plan based on price ID - let plan: "lite" | "pro" | "business"; - - // Get all possible price IDs for each plan - const priceIdToPlan = { - [STRIPE_PLANS.lite.monthlyPriceId.dev]: "lite", - [STRIPE_PLANS.lite.monthlyPriceId.prod]: "lite", - [STRIPE_PLANS.lite.yearlyPriceId.dev]: "lite", - [STRIPE_PLANS.lite.yearlyPriceId.prod]: "lite", - [STRIPE_PLANS.pro.monthlyPriceId.dev]: "pro", - [STRIPE_PLANS.pro.monthlyPriceId.prod]: "pro", - [STRIPE_PLANS.pro.yearlyPriceId.dev]: "pro", - [STRIPE_PLANS.pro.yearlyPriceId.prod]: "pro", - [STRIPE_PLANS.business.monthlyPriceId.dev]: "business", - [STRIPE_PLANS.business.monthlyPriceId.prod]: "business", - [STRIPE_PLANS.business.yearlyPriceId.dev]: "business", - [STRIPE_PLANS.business.yearlyPriceId.prod]: "business", - } as const; - - plan = priceIdToPlan[priceId as keyof typeof priceIdToPlan]; - - if (!plan) { - console.error(`Invalid price ID: ${priceId}`); - throw new Error(`Invalid price ID: ${priceId}`); + .where(eq(users.email, session.customer_details.email)) + .returning({ id: users.id }); + if (updated) { + (await getUserPublishedFormIds(updated.id)).forEach(invalidatePublishedForm); } + } - await db - .update(users) - .set({ plan }) - .where(eq(users.stripeCustomerId, subscription.customer as string)); + if ( + event.type === "customer.subscription.created" || + event.type === "customer.subscription.updated" + ) { + await updateSubscription(event.data.object); } - // Handle subscription deletions if (event.type === "customer.subscription.deleted") { - const subscription = event.data.object as Stripe.Subscription; - - await db + const subscription = event.data.object; + const [updated] = await db .update(users) - .set({ plan: "free" }) - .where(eq(users.stripeCustomerId, subscription.customer as string)); + .set(endedSubscriptionState(subscription.status)) + .where(eq(users.stripeCustomerId, subscription.customer as string)) + .returning({ id: users.id }); + if (updated) { + (await getUserPublishedFormIds(updated.id)).forEach(invalidatePublishedForm); + } } - // Handle failed payments if (event.type === "invoice.payment_failed") { - const invoice = event.data.object as Stripe.Invoice; - - // You might want to notify the user or take other actions - console.error(`Payment failed for customer ${invoice.customer}`); + const invoice = event.data.object; + await db + .update(users) + .set(failedPaymentState()) + .where(eq(users.stripeCustomerId, invoice.customer as string)); } - // Handle customer deletion if (event.type === "customer.deleted") { - const customer = event.data.object as Stripe.Customer; - - await db + const customer = event.data.object; + const [updated] = await db .update(users) .set({ plan: "free", stripeCustomerId: null, + stripeSubscriptionId: null, + stripeSubscriptionStatus: null, + stripeCurrentPeriodEnd: null, + stripeCancelAtPeriodEnd: false, + legacyPriceMigrationRequired: false, }) - .where(eq(users.stripeCustomerId, customer.id)); + .where(eq(users.stripeCustomerId, customer.id)) + .returning({ id: users.id }); + if (updated) { + (await getUserPublishedFormIds(updated.id)).forEach(invalidatePublishedForm); + } } revalidatePath("/"); + revalidatePath("/upgrade"); return NextResponse.json({ success: true }); } catch (error) { console.error("Stripe webhook error:", error); - return NextResponse.json( - { error: "Webhook handler failed" }, - { status: 400 } - ); + return NextResponse.json({ error: "Webhook handler failed" }, { status: 400 }); } } diff --git a/app/endpoints/[id]/page.tsx b/app/endpoints/[id]/page.tsx index 7bfbf0b..9ac7871 100644 --- a/app/endpoints/[id]/page.tsx +++ b/app/endpoints/[id]/page.tsx @@ -19,6 +19,10 @@ import Icon from "@/public/icon.svg"; import CopyButton from "@/components/parts/copy-button"; import { generateShadcnForm } from "@/lib/helpers/generate-form"; import { notFound } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { getFormForEndpoint } from "@/lib/data/forms"; +import { formsNavigationEnabled } from "@/lib/forms/feature-flags"; const pageData = { title: "Endpoint", @@ -34,6 +38,7 @@ export default async function Page({ // fetch endpoint const endpoint = await getEndpointById({ id }); + const attachedForm = await getFormForEndpoint({ endpointId: id }); const { data: endpointData, serverError } = endpoint || {}; // check for errors @@ -88,6 +93,31 @@ export default async function Page({
{`${pageData?.description}`}
+ {formsNavigationEnabled() && ( +
+
+

+ {attachedForm?.data + ? "This endpoint has a form" + : "Add a published presentation"} +

+

+ The endpoint URL and bearer-token API remain available either way. +

+
+ +
+ )} diff --git a/app/f/[publicId]/page.tsx b/app/f/[publicId]/page.tsx new file mode 100644 index 0000000..097d9c6 --- /dev/null +++ b/app/f/[publicId]/page.tsx @@ -0,0 +1,26 @@ +import Script from "next/script"; +import { notFound } from "next/navigation"; +import { getPublishedForm } from "@/lib/data/forms"; +import { publicFormsEnabled } from "@/lib/forms/feature-flags"; + +export default async function HostedFormPage({ + params, +}: { + params: Promise<{ publicId: string }>; +}) { + const { publicId } = await params; + if (!publicFormsEnabled()) notFound(); + const form = await getPublishedForm(publicId); + if (!form) notFound(); + + return ( +
+
+
+
+
+
+ `; + const schemaChanged = useMemo( + () => JSON.stringify(definition) !== JSON.stringify(form.publishedDefinition), + [definition, form.publishedDefinition] + ); + + function updateSelected(patch: Record) { + setDefinition((current) => ({ + ...current, + fields: current.fields.map((field) => + field.id === selectedId ? ({ ...field, ...patch } as FormFieldV1) : field + ), + })); + } + + function addField(kind: FieldKind) { + const field = makeField(kind, definition.fields.length); + setDefinition((current) => ({ ...current, fields: [...current.fields, field] })); + setSelectedId(field.id); + } + + function moveField(fieldId: string, offset: number) { + setDefinition((current) => { + const from = current.fields.findIndex((field) => field.id === fieldId); + const to = Math.max(0, Math.min(current.fields.length - 1, from + offset)); + if (from < 0 || from === to) return current; + const fields = [...current.fields]; + const [field] = fields.splice(from, 1); + fields.splice(to, 0, field); + return { ...current, fields }; + }); + } + + function dropBefore(targetId: string) { + if (!draggedId || draggedId === targetId) return; + setDefinition((current) => { + const fields = [...current.fields]; + const from = fields.findIndex((field) => field.id === draggedId); + const target = fields.findIndex((field) => field.id === targetId); + if (from < 0 || target < 0) return current; + const [field] = fields.splice(from, 1); + fields.splice(from < target ? target - 1 : target, 0, field); + return { ...current, fields }; + }); + setDraggedId(null); + } + + async function handlePublish() { + const valid = formDefinitionV1Schema.safeParse(definition); + if (!valid.success) { + toast.error(valid.error.issues[0]?.message || "Complete the form before publishing."); + return; + } + if ( + form.attachedToExistingEndpoint && + schemaChanged && + !window.confirm( + "Publishing will update the validation schema of this previously headless endpoint. Its URL and bearer token stay the same. Continue?" + ) + ) { + return; + } + const saved = await persistLatest(); + if (!saved && JSON.stringify(latestRef.current) !== lastSavedRef.current) return; + const result = await publishForm({ id: form.id, expectedDraftRevision: revisionRef.current }); + if (!result?.data) { + toast.error(result?.serverError || "Could not publish the form."); + return; + } + setPublishedRevision(result.data.publishedRevision); + setPublishedAt(new Date()); + toast.success("Published. Live placements now use this revision."); + router.refresh(); + } + + async function handleUnpublish() { + if (!window.confirm("Unpublish this form? Existing endpoint API submissions will keep working.")) return; + const result = await unpublishForm({ id: form.id }); + if (result?.serverError) return toast.error(result.serverError); + setPublishedAt(null); + toast.success("Form unpublished."); + router.refresh(); + } + + async function handleDelete() { + if (!window.confirm("Delete this form? Its endpoint and existing leads will be preserved.")) return; + const result = await deleteForm({ id: form.id }); + if (result?.serverError) return toast.error(result.serverError); + router.push("/forms"); + router.refresh(); + } + + async function addOrigin() { + const result = await addFormOrigin({ formId: form.id, origin: originInput }); + if (!result?.data) return toast.error(result?.serverError || "Could not add that origin."); + const addedOrigin = result.data.origin; + const addedOriginId = result.data.id; + setOrigins((current) => [ + ...current.filter((origin) => origin.origin !== addedOrigin), + { id: addedOriginId, origin: addedOrigin, kind: "embed" }, + ]); + setOriginInput(""); + toast.success("Embed origin approved."); + router.refresh(); + } + + function exportDefinition() { + const url = URL.createObjectURL( + new Blob([JSON.stringify(definition, null, 2)], { type: "application/json" }) + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${stableKey(name)}.router-form.json`; + anchor.click(); + URL.revokeObjectURL(url); + } + + async function importDefinition(file: File) { + try { + const parsed = formDefinitionV1Schema.parse(JSON.parse(await file.text())); + setDefinition(parsed); + setSelectedId(parsed.fields[0]?.id ?? null); + toast.success("Definition imported into the draft."); + } catch { + toast.error("That file is not a valid FormDefinitionV1 export."); + } + } + + return ( +
+ + + + diff --git a/dogfood-output/screenshots/desktop-fixed.png b/dogfood-output/screenshots/desktop-fixed.png new file mode 100644 index 0000000000000000000000000000000000000000..a6e7fc7811f99ead9bfa66b84886416c7f1312d1 GIT binary patch literal 57415 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 D=M&RJ literal 0 HcmV?d00001 diff --git a/dogfood-output/screenshots/desktop-initial.png b/dogfood-output/screenshots/desktop-initial.png new file mode 100644 index 0000000000000000000000000000000000000000..73415d8c243f71c3c1afcc24eccacad39701757a GIT binary patch literal 56668 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

h3Pi) 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 AsQ>@~ literal 0 HcmV?d00001 diff --git a/integrations/wordpress/check.sh b/integrations/wordpress/check.sh new file mode 100755 index 0000000..ac28f3e --- /dev/null +++ b/integrations/wordpress/check.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +find "$SCRIPT_DIR/router-forms" -name '*.php' -exec php -l {} \; +node -e 'const block=require(process.argv[1]); if(block.apiVersion!==3||block.name!=="router/forms") process.exit(1)' "$SCRIPT_DIR/router-forms/block.json" +"$SCRIPT_DIR/package.sh" >/dev/null +unzip -t "$SCRIPT_DIR/dist/router-forms.zip" diff --git a/integrations/wordpress/package.sh b/integrations/wordpress/package.sh new file mode 100755 index 0000000..a7f7856 --- /dev/null +++ b/integrations/wordpress/package.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +OUTPUT_DIR="$SCRIPT_DIR/dist" +mkdir -p "$OUTPUT_DIR" +rm -f "$OUTPUT_DIR/router-forms.zip" +cd "$SCRIPT_DIR" +zip -qr "$OUTPUT_DIR/router-forms.zip" router-forms -x '*.DS_Store' +echo "$OUTPUT_DIR/router-forms.zip" diff --git a/integrations/wordpress/router-forms/block.json b/integrations/wordpress/router-forms/block.json new file mode 100644 index 0000000..8e7e9c1 --- /dev/null +++ b/integrations/wordpress/router-forms/block.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "router/forms", + "version": "1.0.0", + "title": "Router Form", + "category": "widgets", + "icon": "feedback", + "description": "Render a published Router form using your theme's typography and colors.", + "textdomain": "router-forms", + "attributes": { + "formId": { + "type": "string", + "default": "" + } + }, + "supports": { + "html": false, + "align": ["wide", "full"], + "spacing": { + "margin": true, + "padding": true + } + }, + "editorScript": "file:./editor.js", + "render": "file:./render.php" +} diff --git a/integrations/wordpress/router-forms/editor.js b/integrations/wordpress/router-forms/editor.js new file mode 100644 index 0000000..50e29cb --- /dev/null +++ b/integrations/wordpress/router-forms/editor.js @@ -0,0 +1,84 @@ +(function (blocks, element, components, blockEditor, apiFetch) { + 'use strict'; + var el = element.createElement; + var useEffect = element.useEffect; + var useState = element.useState; + var InspectorControls = blockEditor.InspectorControls; + var PanelBody = components.PanelBody; + var SelectControl = components.SelectControl; + var Notice = components.Notice; + var Spinner = components.Spinner; + + function Edit(props) { + var state = useState([]); + var forms = state[0]; + var setForms = state[1]; + var loadingState = useState(true); + var loading = loadingState[0]; + var setLoading = loadingState[1]; + var errorState = useState(''); + var error = errorState[0]; + var setError = errorState[1]; + var formId = props.attributes.formId || ''; + + useEffect(function () { + apiFetch({ path: '/router-forms/v1/forms' }) + .then(function (response) { + setForms(response.forms || []); + setLoading(false); + }) + .catch(function (requestError) { + setError(requestError.message || 'Could not load Router forms.'); + setLoading(false); + }); + }, [setForms, setLoading, setError]); + + useEffect(function () { + if (!formId) return; + var existing = document.querySelector('script[data-router-forms-editor]'); + if (existing) { + if (window.RouterFormsV1) window.RouterFormsV1.scan(); + return; + } + var script = document.createElement('script'); + script.src = 'https://forms.router.so/embed/v1.js'; + script.async = true; + script.dataset.routerFormsEditor = 'true'; + document.head.appendChild(script); + }, [formId]); + + var options = [{ label: 'Choose a published form', value: '' }].concat( + forms.map(function (form) { + return { label: form.name + ' — ' + form.title, value: form.publicId }; + }) + ); + + var inspector = el( + InspectorControls, + null, + el( + PanelBody, + { title: 'Router Form', initialOpen: true }, + el(SelectControl, { + label: 'Published form', + value: formId, + options: options, + onChange: function (value) { props.setAttributes({ formId: value }); } + }) + ) + ); + + var content; + if (loading) content = el(Spinner); + else if (error) content = el(Notice, { status: 'error', isDismissible: false }, error); + else if (!formId) content = el(Notice, { status: 'info', isDismissible: false }, 'Choose a published Router form in block settings.'); + else content = el('div', { 'data-router-form': formId, 'data-router-placement': 'wordpress', key: formId }); + + return el('div', blockEditor.useBlockProps(), inspector, content); + } + + blocks.registerBlockType('router/forms', { + edit: Edit, + save: function () { return null; } + }); +})(window.wp.blocks, window.wp.element, window.wp.components, window.wp.blockEditor, window.wp.apiFetch); diff --git a/integrations/wordpress/router-forms/readme.txt b/integrations/wordpress/router-forms/readme.txt new file mode 100644 index 0000000..227e2e2 --- /dev/null +++ b/integrations/wordpress/router-forms/readme.txt @@ -0,0 +1,19 @@ +=== Router Forms === +Contributors: router +Tags: forms, leads, blocks, shortcode +Requires at least: 6.6 +Tested up to: 6.8 +Requires PHP: 7.4 +Stable tag: 1.0.0 +License: GPLv2 or later + +Render published Router forms through a dynamic block or shortcode while inheriting the active WordPress theme. + +== Installation == + +1. Upload and activate the plugin ZIP. +2. Generate a site token in Router under Forms > WordPress. +3. Paste the token under Settings > Router Forms. +4. Insert the Router Form block or use [router_form id="PUBLIC_ID"]. + +The token is stored server-side. Post content stores only the public form ID. diff --git a/integrations/wordpress/router-forms/render.php b/integrations/wordpress/router-forms/render.php new file mode 100644 index 0000000..3229378 --- /dev/null +++ b/integrations/wordpress/router-forms/render.php @@ -0,0 +1,7 @@ +=') && version_compare($wp_version, '6.6', '>='); +} + +function router_forms_admin_requirement_notice() { + if (router_forms_requirements_met()) { + return; + } + echo '

' . esc_html__('Router Forms requires WordPress 6.6+ and PHP 7.4+.', 'router-forms') . '

'; +} +add_action('admin_notices', 'router_forms_admin_requirement_notice'); + +function router_forms_register_runtime() { + wp_register_script( + 'router-forms-runtime', + router_forms_runtime_url(), + array(), + ROUTER_FORMS_VERSION, + array('strategy' => 'async', 'in_footer' => true) + ); +} +add_action('wp_enqueue_scripts', 'router_forms_register_runtime'); +add_action('enqueue_block_editor_assets', 'router_forms_register_runtime'); + +function router_forms_mount_markup($public_id) { + $public_id = sanitize_key($public_id); + if (!$public_id) { + return ''; + } + wp_enqueue_script('router-forms-runtime'); + return sprintf( + '
', + esc_attr($public_id) + ); +} + +function router_forms_shortcode($attributes) { + $attributes = shortcode_atts(array('id' => ''), $attributes, 'router_form'); + return router_forms_mount_markup($attributes['id']); +} +add_shortcode('router_form', 'router_forms_shortcode'); + +function router_forms_register_block() { + if (!router_forms_requirements_met()) { + return; + } + register_block_type(__DIR__); +} +add_action('init', 'router_forms_register_block'); + +function router_forms_register_settings() { + register_setting( + 'router_forms', + ROUTER_FORMS_OPTION, + array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ) + ); +} +add_action('admin_init', 'router_forms_register_settings'); + +function router_forms_add_settings_page() { + add_options_page( + __('Router Forms', 'router-forms'), + __('Router Forms', 'router-forms'), + 'manage_options', + 'router-forms', + 'router_forms_settings_page' + ); +} +add_action('admin_menu', 'router_forms_add_settings_page'); + +function router_forms_settings_page() { + if (!current_user_can('manage_options')) { + return; + } + ?> +
+

+

+
+ + + + + + + + +
+
+ 401)); + } + $response = wp_remote_get( + router_forms_api_url(), + array( + 'timeout' => 10, + 'headers' => array('Authorization' => 'Bearer ' . $token), + ) + ); + if (is_wp_error($response)) { + return new WP_Error('router_forms_unavailable', __('Router is unavailable. Try again shortly.', 'router-forms'), array('status' => 502)); + } + $status = wp_remote_retrieve_response_code($response); + $body = json_decode(wp_remote_retrieve_body($response), true); + if ($status !== 200 || !is_array($body)) { + return new WP_Error('router_forms_connection_failed', __('The Router site token is invalid or revoked.', 'router-forms'), array('status' => 401)); + } + return rest_ensure_response($body); +} + +function router_forms_register_rest_route() { + register_rest_route( + 'router-forms/v1', + '/forms', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => 'router_forms_proxy_form_list', + 'permission_callback' => 'router_forms_rest_permission', + ) + ); +} +add_action('rest_api_init', 'router_forms_register_rest_route'); diff --git a/lib/analytics/server.ts b/lib/analytics/server.ts new file mode 100644 index 0000000..f7391de --- /dev/null +++ b/lib/analytics/server.ts @@ -0,0 +1,26 @@ +export async function captureServerEvent(input: { + event: string; + distinctId: string; + properties?: Record; +}): Promise { + const apiKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; + if (!apiKey) return; + const host = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com"; + try { + await fetch(`${host.replace(/\/$/, "")}/capture/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + api_key: apiKey, + event: input.event, + properties: { + distinct_id: input.distinctId, + ...input.properties, + }, + }), + signal: AbortSignal.timeout(2_000), + }); + } catch { + // Analytics must never block form publication or lead acceptance. + } +} diff --git a/lib/auth/index.ts b/lib/auth/index.ts index e5c3180..c63313d 100644 --- a/lib/auth/index.ts +++ b/lib/auth/index.ts @@ -38,8 +38,16 @@ export const config = { } return token; }, - authorized: async ({ auth }) => { - return !!auth; + authorized: async ({ auth, request }) => { + const hostname = request.nextUrl.hostname; + const pathname = request.nextUrl.pathname; + const isPublicFormSurface = + hostname === "forms.router.so" || + pathname.startsWith("/f/") || + pathname.startsWith("/embed/") || + pathname.startsWith("/api/public/") || + pathname.startsWith("/api/integrations/wordpress/"); + return isPublicFormSurface || !!auth; }, }, pages: { diff --git a/lib/auth/verification.ts b/lib/auth/verification.ts index 77b758f..71653de 100644 --- a/lib/auth/verification.ts +++ b/lib/auth/verification.ts @@ -1,4 +1,4 @@ -import { resend } from "@/lib/utils/resend"; +import { getResend } from "@/lib/utils/resend"; import MagicLinkEmail from "@/components/email/magic-link-email"; export async function sendVerificationRequest(params: { @@ -11,7 +11,7 @@ export async function sendVerificationRequest(params: { const { host } = new URL(url); try { - const data = await resend.emails.send({ + const data = await getResend().emails.send({ from: "info@router.so", to: [identifier], subject: `Log in to ${host}`, diff --git a/lib/constants/stripe.ts b/lib/constants/stripe.ts index e93b787..69afdee 100644 --- a/lib/constants/stripe.ts +++ b/lib/constants/stripe.ts @@ -1,65 +1,48 @@ -interface StripePlanConfig { - productId: { - dev: string; - prod: string; - }; - monthlyPriceId: { - dev: string; - prod: string; - }; - yearlyPriceId: { - dev: string; - prod: string; - }; -} - -interface StripePlansConfig { - lite: StripePlanConfig; - pro: StripePlanConfig; - business: StripePlanConfig; -} +export type PurchasablePlan = "pro" | "business"; +export type BillingInterval = "monthly" | "annual"; -export const STRIPE_PLANS: StripePlansConfig = { - lite: { - productId: { - dev: "prod_RO4s2U30VgdeFN", - prod: "prod_RUs75BH3nWi3Ul", - }, - monthlyPriceId: { - dev: "price_1QVIiNCr7fYvZ7eq3SRX0YGS", - prod: "price_1QbsNLCr7fYvZ7eqoMYV6x6i", - }, - yearlyPriceId: { - dev: "price_1QVIiNCr7fYvZ7eqmJT5DnJc", - prod: "price_1QbsNLCr7fYvZ7eqUl3feFYH", - }, - }, +export const NEW_STRIPE_PRICE_ENV: Record< + PurchasablePlan, + Record +> = { pro: { - productId: { - dev: "prod_RO4sb2253IZWhU", - prod: "prod_RUs7T3eo9UPxDv", - }, - monthlyPriceId: { - dev: "price_1QVIjDCr7fYvZ7eqYZ884nMA", - prod: "price_1QbsNJCr7fYvZ7eqPlAHuLud", - }, - yearlyPriceId: { - dev: "price_1QVIjDCr7fYvZ7eqcw53Mtin", - prod: "price_1QbsNJCr7fYvZ7eqB4M2rvjR", - }, + monthly: "STRIPE_PRO_MONTHLY_PRICE_ID", + annual: "STRIPE_PRO_ANNUAL_PRICE_ID", }, business: { - productId: { - dev: "prod_RO4xe0gGxzWtSb", - prod: "prod_RUs7q0aCgaYhNF", - }, - monthlyPriceId: { - dev: "price_1QVInWCr7fYvZ7eqZ3FSVlFE", - prod: "price_1QbsN7Cr7fYvZ7eqCCdyk03H", - }, - yearlyPriceId: { - dev: "price_1QVInWCr7fYvZ7eqZg6AMiIv", - prod: "price_1QbsN7Cr7fYvZ7eqYxJo3vZd", - }, + monthly: "STRIPE_BUSINESS_MONTHLY_PRICE_ID", + annual: "STRIPE_BUSINESS_ANNUAL_PRICE_ID", }, }; + +/** Existing prices remain recognizable for entitlement continuity only. */ +export const LEGACY_STRIPE_PRICE_TO_PLAN = { + price_1QVIiNCr7fYvZ7eq3SRX0YGS: "lite", + price_1QbsNLCr7fYvZ7eqoMYV6x6i: "lite", + price_1QVIiNCr7fYvZ7eqmJT5DnJc: "lite", + price_1QbsNLCr7fYvZ7eqUl3feFYH: "lite", + price_1QVIjDCr7fYvZ7eqYZ884nMA: "pro", + price_1QbsNJCr7fYvZ7eqPlAHuLud: "pro", + price_1QVIjDCr7fYvZ7eqcw53Mtin: "pro", + price_1QbsNJCr7fYvZ7eqB4M2rvjR: "pro", + price_1QVInWCr7fYvZ7eqZ3FSVlFE: "business", + price_1QbsN7Cr7fYvZ7eqCCdyk03H: "business", + price_1QVInWCr7fYvZ7eqZg6AMiIv: "business", + price_1QbsN7Cr7fYvZ7eqYxJo3vZd: "business", +} as const; + +export function configuredPriceId( + plan: PurchasablePlan, + interval: BillingInterval +): string | null { + return process.env[NEW_STRIPE_PRICE_ENV[plan][interval]] || null; +} + +export function planForNewPrice(priceId: string): PurchasablePlan | null { + for (const plan of ["pro", "business"] as const) { + for (const interval of ["monthly", "annual"] as const) { + if (configuredPriceId(plan, interval) === priceId) return plan; + } + } + return null; +} diff --git a/lib/data/endpoints.ts b/lib/data/endpoints.ts index a90c884..184cdae 100644 --- a/lib/data/endpoints.ts +++ b/lib/data/endpoints.ts @@ -2,10 +2,10 @@ import { revalidatePath } from "next/cache"; import { db, Endpoint } from "../db"; -import { endpoints } from "../db/schema"; +import { endpoints, forms } from "../db/schema"; import { eq, desc, and } from "drizzle-orm"; import { getErrorMessage } from "@/lib/helpers/error-message"; -import { authenticatedAction } from "./safe-action"; +import { ActionError, authenticatedAction } from "./safe-action"; import { z } from "zod"; import { createEndpointFormSchema, @@ -65,6 +65,17 @@ export const getPostingEndpointById = async (id: string) => { export const deleteEndpoint = authenticatedAction .schema(z.object({ id: z.string() })) .action(async ({ parsedInput: { id }, ctx: { userId } }) => { + const [attachedForm] = await db + .select({ id: forms.id }) + .from(forms) + .innerJoin(endpoints, eq(forms.endpointId, endpoints.id)) + .where(and(eq(forms.endpointId, id), eq(endpoints.userId, userId))) + .limit(1); + if (attachedForm) { + throw new ActionError( + "Remove the attached form before deleting this endpoint. Existing leads are preserved when the form is removed." + ); + } await db .delete(endpoints) .where(and(eq(endpoints.id, id), eq(endpoints.userId, userId))); diff --git a/lib/data/forms.ts b/lib/data/forms.ts new file mode 100644 index 0000000..873acdb --- /dev/null +++ b/lib/data/forms.ts @@ -0,0 +1,445 @@ +"use server"; + +import { randomBytes } from "node:crypto"; +import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import { revalidatePath, unstable_cache } from "next/cache"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { + endpoints, + formOrigins, + forms, + users, + wordpressConnections, +} from "@/lib/db/schema"; +import { ActionError, authenticatedAction } from "./safe-action"; +import { + compileEndpointSchema, + formDefinitionV1Schema, + type FormDefinitionV1, +} from "@/lib/forms/definition"; +import { + getStarter, + seedDefinitionFromEndpoint, + type StarterId, +} from "@/lib/forms/starters"; +import { + invalidatePublishedForm, + publishedFormCacheTag, +} from "@/lib/forms/cache"; +import { getEntitlement, type RouterPlan } from "@/lib/forms/entitlements"; +import { normalizeOrigin } from "@/lib/forms/origins"; +import { captureServerEvent } from "@/lib/analytics/server"; + +const starterIdSchema = z.enum([ + "blank", + "contact", + "lead-capture", + "feedback", + "newsletter", +]); + +const createFormInputSchema = z.object({ + name: z.string().trim().min(1).max(120), + starterId: starterIdSchema.default("blank"), + endpointId: z.string().min(1).optional(), +}); + +const saveFormDraftInputSchema = z.object({ + id: z.string().min(1), + expectedRevision: z.number().int().positive(), + name: z.string().trim().min(1).max(120), + definition: formDefinitionV1Schema, +}); + +export const getForms = authenticatedAction.action( + async ({ ctx: { userId } }) => + db + .select({ + id: forms.id, + publicId: forms.publicId, + name: forms.name, + endpointId: forms.endpointId, + endpointName: endpoints.name, + draftRevision: forms.draftRevision, + publishedRevision: forms.publishedRevision, + publishedAt: forms.publishedAt, + updatedAt: forms.updatedAt, + }) + .from(forms) + .innerJoin(endpoints, eq(forms.endpointId, endpoints.id)) + .where(eq(forms.userId, userId)) + .orderBy(desc(forms.updatedAt)) +); + +export const getFormById = authenticatedAction + .schema(z.object({ id: z.string() })) + .action(async ({ parsedInput: { id }, ctx: { userId } }) => { + const [form] = await db + .select({ + id: forms.id, + publicId: forms.publicId, + name: forms.name, + endpointId: forms.endpointId, + endpointName: endpoints.name, + endpointSchema: endpoints.schema, + attachedToExistingEndpoint: forms.attachedToExistingEndpoint, + draftDefinition: forms.draftDefinition, + draftRevision: forms.draftRevision, + publishedDefinition: forms.publishedDefinition, + publishedRevision: forms.publishedRevision, + publishedAt: forms.publishedAt, + updatedAt: forms.updatedAt, + }) + .from(forms) + .innerJoin(endpoints, eq(forms.endpointId, endpoints.id)) + .where(and(eq(forms.id, id), eq(forms.userId, userId))) + .limit(1); + return form; + }); + +export const getFormForEndpoint = authenticatedAction + .schema(z.object({ endpointId: z.string() })) + .action(async ({ parsedInput: { endpointId }, ctx: { userId } }) => { + const [form] = await db + .select({ id: forms.id, name: forms.name }) + .from(forms) + .where(and(eq(forms.endpointId, endpointId), eq(forms.userId, userId))) + .limit(1); + return form; + }); + +export const createForm = authenticatedAction + .schema(createFormInputSchema) + .action(async ({ parsedInput, ctx: { userId } }) => { + const formId = await db.transaction(async (tx) => { + let endpointId = parsedInput.endpointId; + let definition: FormDefinitionV1; + let attachedToExistingEndpoint = false; + + if (endpointId) { + const [endpoint] = await tx + .select() + .from(endpoints) + .where(and(eq(endpoints.id, endpointId), eq(endpoints.userId, userId))) + .limit(1); + if (!endpoint) throw new ActionError("Endpoint not found."); + + const [existingForm] = await tx + .select({ id: forms.id }) + .from(forms) + .where(eq(forms.endpointId, endpointId)) + .limit(1); + if (existingForm) throw new ActionError("This endpoint already has a form."); + + definition = formDefinitionV1Schema.parse( + seedDefinitionFromEndpoint(parsedInput.name, endpoint.schema) + ); + attachedToExistingEndpoint = true; + } else { + definition = formDefinitionV1Schema.parse( + getStarter(parsedInput.starterId as StarterId) + ); + const [endpoint] = await tx + .insert(endpoints) + .values({ + userId, + name: parsedInput.name, + schema: compileEndpointSchema(definition), + token: randomBytes(32).toString("hex"), + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ id: endpoints.id }); + endpointId = endpoint.id; + } + + const [form] = await tx + .insert(forms) + .values({ + userId, + endpointId, + name: parsedInput.name, + draftDefinition: definition, + attachedToExistingEndpoint, + }) + .returning({ id: forms.id }); + + const connections = await tx + .select({ id: wordpressConnections.id, siteOrigin: wordpressConnections.siteOrigin }) + .from(wordpressConnections) + .where( + and( + eq(wordpressConnections.userId, userId), + isNull(wordpressConnections.revokedAt) + ) + ); + if (connections.length) { + await tx + .insert(formOrigins) + .values( + connections.map((connection) => ({ + formId: form.id, + connectionId: connection.id, + origin: connection.siteOrigin, + kind: "wordpress" as const, + })) + ) + .onConflictDoNothing(); + } + return form.id; + }); + + await captureServerEvent({ + event: "form_created", + distinctId: userId, + properties: { form_id: formId }, + }); + + revalidatePath("/forms"); + revalidatePath("/endpoints"); + redirect(`/forms/${formId}`); + }); + +export const saveFormDraft = authenticatedAction + .schema(saveFormDraftInputSchema) + .action(async ({ parsedInput, ctx: { userId } }) => { + const definition = formDefinitionV1Schema.parse(parsedInput.definition); + const [updated] = await db + .update(forms) + .set({ + name: parsedInput.name, + draftDefinition: definition, + draftRevision: sql`${forms.draftRevision} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(forms.id, parsedInput.id), + eq(forms.userId, userId), + eq(forms.draftRevision, parsedInput.expectedRevision) + ) + ) + .returning({ revision: forms.draftRevision, updatedAt: forms.updatedAt }); + + if (!updated) { + throw new ActionError( + "This form changed in another tab. Reload before continuing so you do not overwrite newer work." + ); + } + revalidatePath(`/forms/${parsedInput.id}`); + revalidatePath("/forms"); + return updated; + }); + +export const publishForm = authenticatedAction + .schema(z.object({ id: z.string(), expectedDraftRevision: z.number().int().positive() })) + .action(async ({ parsedInput, ctx: { userId } }) => { + const published = await db.transaction(async (tx) => { + const [form] = await tx + .select() + .from(forms) + .where(and(eq(forms.id, parsedInput.id), eq(forms.userId, userId))) + .limit(1); + if (!form) throw new ActionError("Form not found."); + if (form.draftRevision !== parsedInput.expectedDraftRevision) { + throw new ActionError("Save the latest draft before publishing."); + } + + const definition = formDefinitionV1Schema.parse(form.draftDefinition); + const compiledSchema = compileEndpointSchema(definition); + const now = new Date(); + + await tx + .update(endpoints) + .set({ schema: compiledSchema, updatedAt: now }) + .where(and(eq(endpoints.id, form.endpointId), eq(endpoints.userId, userId))); + + const [updated] = await tx + .update(forms) + .set({ + publishedDefinition: definition, + publishedRevision: sql`${forms.publishedRevision} + 1`, + publishedAt: now, + unpublishedAt: null, + updatedAt: now, + }) + .where(eq(forms.id, form.id)) + .returning({ + publicId: forms.publicId, + publishedRevision: forms.publishedRevision, + }); + return updated; + }); + + invalidatePublishedForm(published.publicId); + await captureServerEvent({ + event: "form_published", + distinctId: userId, + properties: { + form_id: parsedInput.id, + published_revision: published.publishedRevision, + }, + }); + revalidatePath(`/forms/${parsedInput.id}`); + revalidatePath("/forms"); + return published; + }); + +export const unpublishForm = authenticatedAction + .schema(z.object({ id: z.string() })) + .action(async ({ parsedInput: { id }, ctx: { userId } }) => { + const [updated] = await db + .update(forms) + .set({ publishedAt: null, unpublishedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(forms.id, id), eq(forms.userId, userId))) + .returning({ publicId: forms.publicId }); + if (!updated) throw new ActionError("Form not found."); + invalidatePublishedForm(updated.publicId); + revalidatePath(`/forms/${id}`); + revalidatePath("/forms"); + }); + +export const deleteForm = authenticatedAction + .schema(z.object({ id: z.string() })) + .action(async ({ parsedInput: { id }, ctx: { userId } }) => { + const [deleted] = await db + .delete(forms) + .where(and(eq(forms.id, id), eq(forms.userId, userId))) + .returning({ publicId: forms.publicId }); + if (!deleted) throw new ActionError("Form not found."); + invalidatePublishedForm(deleted.publicId); + revalidatePath("/forms"); + revalidatePath("/endpoints"); + }); + +export const getFormOrigins = authenticatedAction + .schema(z.object({ id: z.string() })) + .action(async ({ parsedInput: { id }, ctx: { userId } }) => + db + .select({ + id: formOrigins.id, + origin: formOrigins.origin, + kind: formOrigins.kind, + }) + .from(formOrigins) + .innerJoin(forms, eq(formOrigins.formId, forms.id)) + .where(and(eq(forms.id, id), eq(forms.userId, userId))) + ); + +export const addFormOrigin = authenticatedAction + .schema(z.object({ formId: z.string(), origin: z.string() })) + .action(async ({ parsedInput, ctx: { userId } }) => { + const [ownedForm] = await db + .select({ id: forms.id }) + .from(forms) + .where(and(eq(forms.id, parsedInput.formId), eq(forms.userId, userId))) + .limit(1); + if (!ownedForm) throw new ActionError("Form not found."); + const origin = normalizeOrigin(parsedInput.origin); + const [inserted] = await db + .insert(formOrigins) + .values({ formId: ownedForm.id, origin, kind: "embed" }) + .onConflictDoNothing() + .returning({ id: formOrigins.id }); + const existing = inserted + ? inserted + : ( + await db + .select({ id: formOrigins.id }) + .from(formOrigins) + .where( + and( + eq(formOrigins.formId, ownedForm.id), + eq(formOrigins.origin, origin), + eq(formOrigins.kind, "embed") + ) + ) + .limit(1) + )[0]; + revalidatePath(`/forms/${parsedInput.formId}`); + return { id: existing.id, origin }; + }); + +export const removeFormOrigin = authenticatedAction + .schema(z.object({ formId: z.string(), originId: z.string() })) + .action(async ({ parsedInput, ctx: { userId } }) => { + await db + .delete(formOrigins) + .where( + and( + eq(formOrigins.id, parsedInput.originId), + eq( + formOrigins.formId, + db + .select({ id: forms.id }) + .from(forms) + .where(and(eq(forms.id, parsedInput.formId), eq(forms.userId, userId))) + .limit(1) + ) + ) + ); + revalidatePath(`/forms/${parsedInput.formId}`); + }); + +export type PublishedForm = { + id: string; + publicId: string; + endpointId: string; + ownerId: string; + definition: FormDefinitionV1; + revision: number; + showAttribution: boolean; +}; + +async function loadPublishedForm(publicId: string): Promise { + const [row] = await db + .select({ + id: forms.id, + publicId: forms.publicId, + endpointId: forms.endpointId, + ownerId: forms.userId, + definition: forms.publishedDefinition, + revision: forms.publishedRevision, + plan: users.plan, + }) + .from(forms) + .innerJoin(users, eq(forms.userId, users.id)) + .innerJoin(endpoints, eq(forms.endpointId, endpoints.id)) + .where( + and( + eq(forms.publicId, publicId), + isNotNull(forms.publishedAt), + eq(endpoints.enabled, true) + ) + ) + .limit(1); + + if (!row?.definition) return null; + return { + id: row.id, + publicId: row.publicId, + endpointId: row.endpointId, + ownerId: row.ownerId, + definition: formDefinitionV1Schema.parse(row.definition), + revision: row.revision, + showAttribution: getEntitlement(row.plan as RouterPlan).showAttribution, + }; +} + +export async function getPublishedForm(publicId: string): Promise { + return unstable_cache( + () => loadPublishedForm(publicId), + ["published-form", publicId], + { tags: [publishedFormCacheTag(publicId)], revalidate: 3600 } + )(); +} + +export async function getUserPublishedFormIds(userId: string): Promise { + const rows = await db + .select({ publicId: forms.publicId }) + .from(forms) + .where(and(eq(forms.userId, userId), isNotNull(forms.publishedAt))); + return rows.map((row) => row.publicId); +} diff --git a/lib/data/leads.ts b/lib/data/leads.ts index 37218d1..6d0fa36 100644 --- a/lib/data/leads.ts +++ b/lib/data/leads.ts @@ -1,6 +1,6 @@ "use server"; -import { leads, endpoints } from "../db/schema"; +import { leads, endpoints, forms } from "../db/schema"; import { eq, desc, and } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { db } from "../db"; @@ -54,6 +54,9 @@ export const getLeads = authenticatedAction.action( updatedAt: lead.lead.updatedAt, endpointId: lead.endpoint?.id as string, endpoint: lead.endpoint?.name || undefined, + formId: lead.lead.formId, + formRevision: lead.lead.formRevision, + placement: lead.lead.placement, })); return data; @@ -116,6 +119,23 @@ export const getLeadsByEndpoint = authenticatedAction return { leadData, schema: endpoint[0].schema }; }); +export const getLeadsByForm = authenticatedAction + .schema(z.object({ id: z.string() })) + .action(async ({ parsedInput: { id }, ctx: { userId } }) => { + const [ownedForm] = await db + .select({ id: forms.id, endpointId: forms.endpointId }) + .from(forms) + .where(and(eq(forms.id, id), eq(forms.userId, userId))) + .limit(1); + if (!ownedForm) throw new Error("You are not authorized for this action."); + + return db + .select() + .from(leads) + .where(eq(leads.formId, ownedForm.id)) + .orderBy(desc(leads.createdAt)); + }); + /** * Delete a lead by id * diff --git a/lib/data/safe-action.ts b/lib/data/safe-action.ts index 20387f3..9a96372 100644 --- a/lib/data/safe-action.ts +++ b/lib/data/safe-action.ts @@ -4,7 +4,7 @@ import { } from "next-safe-action"; import { auth } from "../auth"; -class ActionError extends Error {} +export class ActionError extends Error {} /** * Creates a client of next-safe-action to use in server actions diff --git a/lib/data/stripe.ts b/lib/data/stripe.ts index d6ac3ff..3d5575e 100644 --- a/lib/data/stripe.ts +++ b/lib/data/stripe.ts @@ -1,50 +1,45 @@ "use server"; -import { Stripe } from "stripe"; import { headers } from "next/headers"; -import { authenticatedAction } from "./safe-action"; +import { redirect } from "next/navigation"; import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { ActionError, authenticatedAction } from "./safe-action"; import { db } from "../db"; import { users } from "../db/schema"; -import { eq } from "drizzle-orm"; -import { redirect } from "next/navigation"; - -const apiKey = process.env.STRIPE_SECRET_KEY!; - -const stripe = new Stripe(apiKey); +import { configuredPriceId } from "@/lib/constants/stripe"; +import { getStripe } from "@/lib/utils/stripe-client"; const createStripeSessionSchema = z.object({ - priceId: z.string(), + plan: z.enum(["pro", "business"]), + interval: z.enum(["monthly", "annual"]), }); export const postStripeSession = authenticatedAction .schema(createStripeSessionSchema) .action(async ({ parsedInput, ctx: { userId } }) => { + const priceId = configuredPriceId(parsedInput.plan, parsedInput.interval); + if (!priceId) throw new ActionError("The new Router price is not configured yet."); const host = (await headers()).get("host"); const protocol = process.env.NODE_ENV === "production" ? "https" : "http"; - const [{ email }] = await db .select({ email: users.email }) .from(users) .where(eq(users.id, userId)); - const session = await stripe.checkout.sessions.create({ - line_items: [ - { - price: parsedInput.priceId, - quantity: 1, - }, - ], + const session = await getStripe().checkout.sessions.create({ + line_items: [{ price: priceId, quantity: 1 }], mode: "subscription", customer_email: email, - success_url: `${protocol}://${host}/`, + success_url: `${protocol}://${host}/upgrade?checkout=success`, + cancel_url: `${protocol}://${host}/upgrade`, allow_promotion_codes: true, + metadata: { routerPlan: parsedInput.plan }, + subscription_data: { + metadata: { routerUserId: userId, routerPlan: parsedInput.plan }, + }, }); - - if (!session.url) { - throw new Error("Failed to create Stripe checkout session"); - } - + if (!session.url) throw new ActionError("Failed to create Stripe checkout session."); redirect(session.url); }); @@ -52,31 +47,23 @@ export const createCustomerPortalSession = authenticatedAction.action( async ({ ctx: { userId } }) => { const host = (await headers()).get("host"); const protocol = process.env.NODE_ENV === "production" ? "https" : "http"; - - const [{ email }] = await db - .select({ email: users.email }) + const [{ email, stripeCustomerId }] = await db + .select({ email: users.email, stripeCustomerId: users.stripeCustomerId }) .from(users) .where(eq(users.id, userId)); - // Get Stripe customer ID - const customer = await stripe.customers.list({ - email, - limit: 1, - }); - - if (!customer.data[0]?.id) { - throw new Error("No Stripe customer found"); + let customerId = stripeCustomerId; + if (!customerId) { + const customer = await getStripe().customers.list({ email, limit: 1 }); + customerId = customer.data[0]?.id ?? null; } + if (!customerId) throw new ActionError("No Stripe customer found."); - const session = await stripe.billingPortal.sessions.create({ - customer: customer.data[0].id, + const session = await getStripe().billingPortal.sessions.create({ + customer: customerId, return_url: `${protocol}://${host}/upgrade`, }); - - if (!session.url) { - throw new Error("Failed to create customer portal session"); - } - + if (!session.url) throw new ActionError("Failed to create customer portal session."); redirect(session.url); - }, + } ); diff --git a/lib/data/users.ts b/lib/data/users.ts index 0a5588c..811c958 100644 --- a/lib/data/users.ts +++ b/lib/data/users.ts @@ -1,8 +1,8 @@ "use server"; import { db } from "../db"; -import { users, endpoints } from "../db/schema"; -import { eq, sql } from "drizzle-orm"; +import { users, endpoints, usagePeriods } from "../db/schema"; +import { and, eq, sql } from "drizzle-orm"; import { authenticatedAction } from "./safe-action"; /** @@ -74,9 +74,18 @@ export const getUserPlan = async (endpointId: string) => { * Runs once a month on a CRON trigger */ export const clearLeadCount = async () => { + // Kept only as a compatibility mirror. usagePeriods is the authoritative, + // non-resettable UTC calendar-month counter. await db.update(users).set({ leadCount: 0 }); }; +function currentUtcPeriodStart(now = new Date()): string { + return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart( + 2, + "0" + )}-01`; +} + /** * Retrieves the lead count for specific user * @@ -86,14 +95,28 @@ export const clearLeadCount = async () => { export const getUsageForUser = authenticatedAction.action( async ({ ctx: { userId } }) => { const result = await db - .select({ leadCount: users.leadCount, plan: users.plan }) + .select({ + leadCount: usagePeriods.leadCount, + plan: users.plan, + legacyPriceMigrationRequired: users.legacyPriceMigrationRequired, + stripeCurrentPeriodEnd: users.stripeCurrentPeriodEnd, + stripeCancelAtPeriodEnd: users.stripeCancelAtPeriodEnd, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + }) .from(users) + .leftJoin( + usagePeriods, + and( + eq(usagePeriods.userId, users.id), + eq(usagePeriods.periodStart, currentUtcPeriodStart()) + ) + ) .where(eq(users.id, userId)); if (result.length === 0) { throw new Error("User not found"); } - return result[0]; + return { ...result[0], leadCount: result[0].leadCount ?? 0 }; } ); diff --git a/lib/data/validations.ts b/lib/data/validations.ts index 14cf4ec..92f1b0a 100644 --- a/lib/data/validations.ts +++ b/lib/data/validations.ts @@ -9,7 +9,17 @@ export const getLeadDataSchema = z.object({ }); const ValidationType = z.enum( - ["phone", "email", "string", "number", "date", "boolean", "url", "zip_code"], + [ + "phone", + "email", + "string", + "number", + "date", + "boolean", + "url", + "zip_code", + "string_array", + ], { errorMap: () => ({ message: "Please select a valid field type." }), } diff --git a/lib/data/wordpress.ts b/lib/data/wordpress.ts new file mode 100644 index 0000000..ab02b16 --- /dev/null +++ b/lib/data/wordpress.ts @@ -0,0 +1,157 @@ +"use server"; + +import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { + formOrigins, + forms, + wordpressConnections, +} from "@/lib/db/schema"; +import { ActionError, authenticatedAction } from "./safe-action"; +import { normalizeOrigin } from "@/lib/forms/origins"; +import { + createWordPressToken, + hashWordPressToken, + tokenPrefix, +} from "@/lib/forms/wordpress-token"; +import { captureServerEvent } from "@/lib/analytics/server"; + +export const getWordPressConnections = authenticatedAction.action( + async ({ ctx: { userId } }) => + db + .select({ + id: wordpressConnections.id, + siteOrigin: wordpressConnections.siteOrigin, + siteName: wordpressConnections.siteName, + tokenPrefix: wordpressConnections.tokenPrefix, + lastUsedAt: wordpressConnections.lastUsedAt, + revokedAt: wordpressConnections.revokedAt, + createdAt: wordpressConnections.createdAt, + }) + .from(wordpressConnections) + .where(eq(wordpressConnections.userId, userId)) + .orderBy(desc(wordpressConnections.createdAt)) +); + +export const createWordPressConnection = authenticatedAction + .schema( + z.object({ + siteUrl: z.string().min(1), + siteName: z.string().trim().max(120).optional(), + }) + ) + .action(async ({ parsedInput, ctx: { userId } }) => { + const siteOrigin = normalizeOrigin(parsedInput.siteUrl); + const [existingConnection] = await db + .select({ id: wordpressConnections.id }) + .from(wordpressConnections) + .where( + and( + eq(wordpressConnections.userId, userId), + eq(wordpressConnections.siteOrigin, siteOrigin), + isNull(wordpressConnections.revokedAt) + ) + ) + .limit(1); + if (existingConnection) { + throw new ActionError("This WordPress site already has an active connection."); + } + const token = createWordPressToken(); + const now = new Date(); + const connection = await db.transaction(async (tx) => { + const [created] = await tx + .insert(wordpressConnections) + .values({ + userId, + siteOrigin, + siteName: parsedInput.siteName || null, + tokenPrefix: tokenPrefix(token), + tokenHash: hashWordPressToken(token), + createdAt: now, + updatedAt: now, + }) + .returning({ id: wordpressConnections.id }); + + const userForms = await tx + .select({ id: forms.id }) + .from(forms) + .where(eq(forms.userId, userId)); + if (userForms.length) { + await tx + .insert(formOrigins) + .values( + userForms.map((form) => ({ + formId: form.id, + connectionId: created.id, + origin: siteOrigin, + kind: "wordpress" as const, + })) + ) + .onConflictDoNothing(); + } + return created; + }); + + await captureServerEvent({ + event: "form_wordpress_connected", + distinctId: userId, + properties: { connection_id: connection.id }, + }); + + revalidatePath("/forms/wordpress"); + return { id: connection.id, token, tokenPrefix: tokenPrefix(token), siteOrigin }; + }); + +export const revokeWordPressConnection = authenticatedAction + .schema(z.object({ id: z.string() })) + .action(async ({ parsedInput: { id }, ctx: { userId } }) => { + await db.transaction(async (tx) => { + const [connection] = await tx + .update(wordpressConnections) + .set({ revokedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(wordpressConnections.id, id), + eq(wordpressConnections.userId, userId), + isNull(wordpressConnections.revokedAt) + ) + ) + .returning({ id: wordpressConnections.id }); + if (!connection) throw new ActionError("Connection not found."); + await tx.delete(formOrigins).where(eq(formOrigins.connectionId, connection.id)); + }); + revalidatePath("/forms/wordpress"); + }); + +export async function listPublishedFormsForWordPressToken(token: string) { + const hash = hashWordPressToken(token); + const [connection] = await db + .select({ id: wordpressConnections.id, userId: wordpressConnections.userId }) + .from(wordpressConnections) + .where( + and( + eq(wordpressConnections.tokenHash, hash), + isNull(wordpressConnections.revokedAt) + ) + ) + .limit(1); + if (!connection) return null; + + await db + .update(wordpressConnections) + .set({ lastUsedAt: new Date(), updatedAt: new Date() }) + .where(eq(wordpressConnections.id, connection.id)); + + return db + .select({ + publicId: forms.publicId, + name: forms.name, + title: forms.publishedDefinition, + revision: forms.publishedRevision, + }) + .from(forms) + .where(and(eq(forms.userId, connection.userId), isNotNull(forms.publishedAt))) + .orderBy(desc(forms.updatedAt)); +} diff --git a/lib/db/drizzle/0006_router_forms_mvp.sql b/lib/db/drizzle/0006_router_forms_mvp.sql new file mode 100644 index 0000000..59e3aaa --- /dev/null +++ b/lib/db/drizzle/0006_router_forms_mvp.sql @@ -0,0 +1,93 @@ +CREATE TYPE "public"."formOriginKind" AS ENUM('embed', 'wordpress');--> statement-breakpoint +CREATE TYPE "public"."formPlacement" AS ENUM('headless', 'legacy_html', 'hosted', 'embed', 'wordpress');--> statement-breakpoint +CREATE TABLE "formOrigin" ( + "id" text PRIMARY KEY NOT NULL, + "formId" text NOT NULL, + "connectionId" text, + "origin" text NOT NULL, + "kind" "formOriginKind" NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "formRateBucket" ( + "formId" text NOT NULL, + "bucketKey" text NOT NULL, + "windowStart" timestamp with time zone NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "formRateBucket_formId_bucketKey_windowStart_pk" PRIMARY KEY("formId","bucketKey","windowStart") +); +--> statement-breakpoint +CREATE TABLE "form" ( + "id" text PRIMARY KEY NOT NULL, + "userId" text NOT NULL, + "endpointId" text NOT NULL, + "publicId" text NOT NULL, + "name" text NOT NULL, + "draftDefinition" jsonb NOT NULL, + "draftRevision" integer DEFAULT 1 NOT NULL, + "publishedDefinition" jsonb, + "publishedRevision" integer DEFAULT 0 NOT NULL, + "publishedAt" timestamp with time zone, + "unpublishedAt" timestamp with time zone, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "usagePeriod" ( + "userId" text NOT NULL, + "periodStart" date NOT NULL, + "leadCount" integer DEFAULT 0 NOT NULL, + "notifiedAt80" timestamp with time zone, + "notifiedAt100" timestamp with time zone, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "usagePeriod_userId_periodStart_pk" PRIMARY KEY("userId","periodStart") +); +--> statement-breakpoint +CREATE TABLE "wordpressConnection" ( + "id" text PRIMARY KEY NOT NULL, + "userId" text NOT NULL, + "siteOrigin" text NOT NULL, + "siteName" text, + "tokenPrefix" text NOT NULL, + "tokenHash" text NOT NULL, + "lastUsedAt" timestamp with time zone, + "revokedAt" timestamp with time zone, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "lead" ADD COLUMN "formId" text;--> statement-breakpoint +ALTER TABLE "lead" ADD COLUMN "formRevision" integer;--> statement-breakpoint +ALTER TABLE "lead" ADD COLUMN "placement" "formPlacement";--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "stripeSubscriptionId" text;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "stripeSubscriptionStatus" text;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "stripeCurrentPeriodEnd" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "legacyPriceMigrationRequired" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "formOrigin" ADD CONSTRAINT "formOrigin_formId_form_id_fk" FOREIGN KEY ("formId") REFERENCES "public"."form"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "formOrigin" ADD CONSTRAINT "formOrigin_connectionId_wordpressConnection_id_fk" FOREIGN KEY ("connectionId") REFERENCES "public"."wordpressConnection"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "formRateBucket" ADD CONSTRAINT "formRateBucket_formId_form_id_fk" FOREIGN KEY ("formId") REFERENCES "public"."form"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "form" ADD CONSTRAINT "form_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "form" ADD CONSTRAINT "form_endpointId_endpoint_id_fk" FOREIGN KEY ("endpointId") REFERENCES "public"."endpoint"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "usagePeriod" ADD CONSTRAINT "usagePeriod_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wordpressConnection" ADD CONSTRAINT "wordpressConnection_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "form_origin_unique" ON "formOrigin" USING btree ("formId","origin");--> statement-breakpoint +CREATE INDEX "form_rate_bucket_prune_idx" ON "formRateBucket" USING btree ("updatedAt");--> statement-breakpoint +CREATE UNIQUE INDEX "form_endpoint_unique" ON "form" USING btree ("endpointId");--> statement-breakpoint +CREATE UNIQUE INDEX "form_public_id_unique" ON "form" USING btree ("publicId");--> statement-breakpoint +CREATE INDEX "form_owner_updated_idx" ON "form" USING btree ("userId","updatedAt");--> statement-breakpoint +CREATE UNIQUE INDEX "wordpress_connection_token_hash_unique" ON "wordpressConnection" USING btree ("tokenHash");--> statement-breakpoint +CREATE INDEX "wordpress_connection_owner_site_idx" ON "wordpressConnection" USING btree ("userId","siteOrigin");--> statement-breakpoint +ALTER TABLE "lead" ADD CONSTRAINT "lead_formId_form_id_fk" FOREIGN KEY ("formId") REFERENCES "public"."form"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +INSERT INTO "usagePeriod" ("userId", "periodStart", "leadCount", "updatedAt") +SELECT + "endpoint"."userId", + date_trunc('month', CURRENT_TIMESTAMP)::date, + count("lead"."id")::integer, + CURRENT_TIMESTAMP +FROM "lead" +INNER JOIN "endpoint" ON "lead"."endpointId" = "endpoint"."id" +WHERE "lead"."createdAt" >= date_trunc('month', CURRENT_TIMESTAMP) +GROUP BY "endpoint"."userId" +ON CONFLICT ("userId", "periodStart") DO UPDATE +SET "leadCount" = EXCLUDED."leadCount", "updatedAt" = CURRENT_TIMESTAMP; diff --git a/lib/db/drizzle/0007_form_attachment_provenance.sql b/lib/db/drizzle/0007_form_attachment_provenance.sql new file mode 100644 index 0000000..aaca194 --- /dev/null +++ b/lib/db/drizzle/0007_form_attachment_provenance.sql @@ -0,0 +1 @@ +ALTER TABLE "form" ADD COLUMN "attachedToExistingEndpoint" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/lib/db/drizzle/0008_stripe_migration_state.sql b/lib/db/drizzle/0008_stripe_migration_state.sql new file mode 100644 index 0000000..2f403a9 --- /dev/null +++ b/lib/db/drizzle/0008_stripe_migration_state.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "stripeCancelAtPeriodEnd" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/lib/db/drizzle/0009_form_origin_kind_uniqueness.sql b/lib/db/drizzle/0009_form_origin_kind_uniqueness.sql new file mode 100644 index 0000000..bffff27 --- /dev/null +++ b/lib/db/drizzle/0009_form_origin_kind_uniqueness.sql @@ -0,0 +1,2 @@ +DROP INDEX "form_origin_unique";--> statement-breakpoint +CREATE UNIQUE INDEX "form_origin_unique" ON "formOrigin" USING btree ("formId","origin","kind"); \ No newline at end of file diff --git a/lib/db/drizzle/0010_placement_first_lead_analytics.sql b/lib/db/drizzle/0010_placement_first_lead_analytics.sql new file mode 100644 index 0000000..8345f87 --- /dev/null +++ b/lib/db/drizzle/0010_placement_first_lead_analytics.sql @@ -0,0 +1,9 @@ +CREATE TABLE "formPlacementMilestone" ( + "formId" text NOT NULL, + "placement" "formPlacement" NOT NULL, + "firstLeadId" text NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "formPlacementMilestone_formId_placement_pk" PRIMARY KEY("formId","placement") +); +--> statement-breakpoint +ALTER TABLE "formPlacementMilestone" ADD CONSTRAINT "formPlacementMilestone_formId_form_id_fk" FOREIGN KEY ("formId") REFERENCES "public"."form"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/lib/db/drizzle/meta/0006_snapshot.json b/lib/db/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..b8d3753 --- /dev/null +++ b/lib/db/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1175 @@ +{ + "id": "c79c6ad5-cc8f-47e6-8cdf-fdd542a6aea4", + "prevId": "6d115309-b41f-4c9d-a7a8-5b38762f7605", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.endpoint": { + "name": "endpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhookEnabled": { + "name": "webhookEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "emailNotify": { + "name": "emailNotify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook": { + "name": "webhook", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formEnabled": { + "name": "formEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "successUrl": { + "name": "successUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failUrl": { + "name": "failUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "endpoint_userId_user_id_fk": { + "name": "endpoint_userId_user_id_fk", + "tableFrom": "endpoint", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formOrigin": { + "name": "formOrigin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "formOriginKind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_origin_unique": { + "name": "form_origin_unique", + "columns": [ + { + "expression": "formId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formOrigin_formId_form_id_fk": { + "name": "formOrigin_formId_form_id_fk", + "tableFrom": "formOrigin", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "formOrigin_connectionId_wordpressConnection_id_fk": { + "name": "formOrigin_connectionId_wordpressConnection_id_fk", + "tableFrom": "formOrigin", + "tableTo": "wordpressConnection", + "columnsFrom": [ + "connectionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formRateBucket": { + "name": "formRateBucket", + "schema": "", + "columns": { + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucketKey": { + "name": "bucketKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windowStart": { + "name": "windowStart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_rate_bucket_prune_idx": { + "name": "form_rate_bucket_prune_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formRateBucket_formId_form_id_fk": { + "name": "formRateBucket_formId_form_id_fk", + "tableFrom": "formRateBucket", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "formRateBucket_formId_bucketKey_windowStart_pk": { + "name": "formRateBucket_formId_bucketKey_windowStart_pk", + "columns": [ + "formId", + "bucketKey", + "windowStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draftDefinition": { + "name": "draftDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "draftRevision": { + "name": "draftRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "publishedDefinition": { + "name": "publishedDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishedRevision": { + "name": "publishedRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unpublishedAt": { + "name": "unpublishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_endpoint_unique": { + "name": "form_endpoint_unique", + "columns": [ + { + "expression": "endpointId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_public_id_unique": { + "name": "form_public_id_unique", + "columns": [ + { + "expression": "publicId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_owner_updated_idx": { + "name": "form_owner_updated_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_userId_user_id_fk": { + "name": "form_userId_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_endpointId_endpoint_id_fk": { + "name": "form_endpointId_endpoint_id_fk", + "tableFrom": "form", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead": { + "name": "lead", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formRevision": { + "name": "formRevision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "placement": { + "name": "placement", + "type": "formPlacement", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "lead_endpointId_endpoint_id_fk": { + "name": "lead_endpointId_endpoint_id_fk", + "tableFrom": "lead", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_formId_form_id_fk": { + "name": "lead_formId_form_id_fk", + "tableFrom": "lead", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log": { + "name": "log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "logType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "postType": { + "name": "postType", + "type": "logPostType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "log_endpointId_endpoint_id_fk": { + "name": "log_endpointId_endpoint_id_fk", + "tableFrom": "log", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usagePeriod": { + "name": "usagePeriod", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "periodStart": { + "name": "periodStart", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notifiedAt80": { + "name": "notifiedAt80", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifiedAt100": { + "name": "notifiedAt100", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "usagePeriod_userId_user_id_fk": { + "name": "usagePeriod_userId_user_id_fk", + "tableFrom": "usagePeriod", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "usagePeriod_userId_periodStart_pk": { + "name": "usagePeriod_userId_periodStart_pk", + "columns": [ + "userId", + "periodStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "plan": { + "name": "plan", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeCurrentPeriodEnd": { + "name": "stripeCurrentPeriodEnd", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "legacyPriceMigrationRequired": { + "name": "legacyPriceMigrationRequired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wordpressConnection": { + "name": "wordpressConnection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteOrigin": { + "name": "siteOrigin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteName": { + "name": "siteName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wordpress_connection_token_hash_unique": { + "name": "wordpress_connection_token_hash_unique", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wordpress_connection_owner_site_idx": { + "name": "wordpress_connection_owner_site_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "siteOrigin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wordpressConnection_userId_user_id_fk": { + "name": "wordpressConnection_userId_user_id_fk", + "tableFrom": "wordpressConnection", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.formOriginKind": { + "name": "formOriginKind", + "schema": "public", + "values": [ + "embed", + "wordpress" + ] + }, + "public.formPlacement": { + "name": "formPlacement", + "schema": "public", + "values": [ + "headless", + "legacy_html", + "hosted", + "embed", + "wordpress" + ] + }, + "public.logPostType": { + "name": "logPostType", + "schema": "public", + "values": [ + "http", + "form", + "webhook", + "email" + ] + }, + "public.logType": { + "name": "logType", + "schema": "public", + "values": [ + "success", + "error" + ] + }, + "public.plan": { + "name": "plan", + "schema": "public", + "values": [ + "free", + "lite", + "pro", + "business", + "enterprise" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/drizzle/meta/0007_snapshot.json b/lib/db/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..6bb2992 --- /dev/null +++ b/lib/db/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1182 @@ +{ + "id": "61be97fa-6ee7-4c2c-bb93-014c27623931", + "prevId": "c79c6ad5-cc8f-47e6-8cdf-fdd542a6aea4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.endpoint": { + "name": "endpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhookEnabled": { + "name": "webhookEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "emailNotify": { + "name": "emailNotify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook": { + "name": "webhook", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formEnabled": { + "name": "formEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "successUrl": { + "name": "successUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failUrl": { + "name": "failUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "endpoint_userId_user_id_fk": { + "name": "endpoint_userId_user_id_fk", + "tableFrom": "endpoint", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formOrigin": { + "name": "formOrigin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "formOriginKind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_origin_unique": { + "name": "form_origin_unique", + "columns": [ + { + "expression": "formId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formOrigin_formId_form_id_fk": { + "name": "formOrigin_formId_form_id_fk", + "tableFrom": "formOrigin", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "formOrigin_connectionId_wordpressConnection_id_fk": { + "name": "formOrigin_connectionId_wordpressConnection_id_fk", + "tableFrom": "formOrigin", + "tableTo": "wordpressConnection", + "columnsFrom": [ + "connectionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formRateBucket": { + "name": "formRateBucket", + "schema": "", + "columns": { + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucketKey": { + "name": "bucketKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windowStart": { + "name": "windowStart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_rate_bucket_prune_idx": { + "name": "form_rate_bucket_prune_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formRateBucket_formId_form_id_fk": { + "name": "formRateBucket_formId_form_id_fk", + "tableFrom": "formRateBucket", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "formRateBucket_formId_bucketKey_windowStart_pk": { + "name": "formRateBucket_formId_bucketKey_windowStart_pk", + "columns": [ + "formId", + "bucketKey", + "windowStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachedToExistingEndpoint": { + "name": "attachedToExistingEndpoint", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "draftDefinition": { + "name": "draftDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "draftRevision": { + "name": "draftRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "publishedDefinition": { + "name": "publishedDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishedRevision": { + "name": "publishedRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unpublishedAt": { + "name": "unpublishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_endpoint_unique": { + "name": "form_endpoint_unique", + "columns": [ + { + "expression": "endpointId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_public_id_unique": { + "name": "form_public_id_unique", + "columns": [ + { + "expression": "publicId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_owner_updated_idx": { + "name": "form_owner_updated_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_userId_user_id_fk": { + "name": "form_userId_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_endpointId_endpoint_id_fk": { + "name": "form_endpointId_endpoint_id_fk", + "tableFrom": "form", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead": { + "name": "lead", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formRevision": { + "name": "formRevision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "placement": { + "name": "placement", + "type": "formPlacement", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "lead_endpointId_endpoint_id_fk": { + "name": "lead_endpointId_endpoint_id_fk", + "tableFrom": "lead", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_formId_form_id_fk": { + "name": "lead_formId_form_id_fk", + "tableFrom": "lead", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log": { + "name": "log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "logType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "postType": { + "name": "postType", + "type": "logPostType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "log_endpointId_endpoint_id_fk": { + "name": "log_endpointId_endpoint_id_fk", + "tableFrom": "log", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usagePeriod": { + "name": "usagePeriod", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "periodStart": { + "name": "periodStart", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notifiedAt80": { + "name": "notifiedAt80", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifiedAt100": { + "name": "notifiedAt100", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "usagePeriod_userId_user_id_fk": { + "name": "usagePeriod_userId_user_id_fk", + "tableFrom": "usagePeriod", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "usagePeriod_userId_periodStart_pk": { + "name": "usagePeriod_userId_periodStart_pk", + "columns": [ + "userId", + "periodStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "plan": { + "name": "plan", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeCurrentPeriodEnd": { + "name": "stripeCurrentPeriodEnd", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "legacyPriceMigrationRequired": { + "name": "legacyPriceMigrationRequired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wordpressConnection": { + "name": "wordpressConnection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteOrigin": { + "name": "siteOrigin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteName": { + "name": "siteName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wordpress_connection_token_hash_unique": { + "name": "wordpress_connection_token_hash_unique", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wordpress_connection_owner_site_idx": { + "name": "wordpress_connection_owner_site_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "siteOrigin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wordpressConnection_userId_user_id_fk": { + "name": "wordpressConnection_userId_user_id_fk", + "tableFrom": "wordpressConnection", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.formOriginKind": { + "name": "formOriginKind", + "schema": "public", + "values": [ + "embed", + "wordpress" + ] + }, + "public.formPlacement": { + "name": "formPlacement", + "schema": "public", + "values": [ + "headless", + "legacy_html", + "hosted", + "embed", + "wordpress" + ] + }, + "public.logPostType": { + "name": "logPostType", + "schema": "public", + "values": [ + "http", + "form", + "webhook", + "email" + ] + }, + "public.logType": { + "name": "logType", + "schema": "public", + "values": [ + "success", + "error" + ] + }, + "public.plan": { + "name": "plan", + "schema": "public", + "values": [ + "free", + "lite", + "pro", + "business", + "enterprise" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/drizzle/meta/0008_snapshot.json b/lib/db/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..30e43f1 --- /dev/null +++ b/lib/db/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1189 @@ +{ + "id": "b73038f2-57b2-45ab-a526-850b29679d24", + "prevId": "61be97fa-6ee7-4c2c-bb93-014c27623931", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.endpoint": { + "name": "endpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhookEnabled": { + "name": "webhookEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "emailNotify": { + "name": "emailNotify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook": { + "name": "webhook", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formEnabled": { + "name": "formEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "successUrl": { + "name": "successUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failUrl": { + "name": "failUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "endpoint_userId_user_id_fk": { + "name": "endpoint_userId_user_id_fk", + "tableFrom": "endpoint", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formOrigin": { + "name": "formOrigin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "formOriginKind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_origin_unique": { + "name": "form_origin_unique", + "columns": [ + { + "expression": "formId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formOrigin_formId_form_id_fk": { + "name": "formOrigin_formId_form_id_fk", + "tableFrom": "formOrigin", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "formOrigin_connectionId_wordpressConnection_id_fk": { + "name": "formOrigin_connectionId_wordpressConnection_id_fk", + "tableFrom": "formOrigin", + "tableTo": "wordpressConnection", + "columnsFrom": [ + "connectionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formRateBucket": { + "name": "formRateBucket", + "schema": "", + "columns": { + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucketKey": { + "name": "bucketKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windowStart": { + "name": "windowStart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_rate_bucket_prune_idx": { + "name": "form_rate_bucket_prune_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formRateBucket_formId_form_id_fk": { + "name": "formRateBucket_formId_form_id_fk", + "tableFrom": "formRateBucket", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "formRateBucket_formId_bucketKey_windowStart_pk": { + "name": "formRateBucket_formId_bucketKey_windowStart_pk", + "columns": [ + "formId", + "bucketKey", + "windowStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachedToExistingEndpoint": { + "name": "attachedToExistingEndpoint", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "draftDefinition": { + "name": "draftDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "draftRevision": { + "name": "draftRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "publishedDefinition": { + "name": "publishedDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishedRevision": { + "name": "publishedRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unpublishedAt": { + "name": "unpublishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_endpoint_unique": { + "name": "form_endpoint_unique", + "columns": [ + { + "expression": "endpointId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_public_id_unique": { + "name": "form_public_id_unique", + "columns": [ + { + "expression": "publicId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_owner_updated_idx": { + "name": "form_owner_updated_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_userId_user_id_fk": { + "name": "form_userId_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_endpointId_endpoint_id_fk": { + "name": "form_endpointId_endpoint_id_fk", + "tableFrom": "form", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead": { + "name": "lead", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formRevision": { + "name": "formRevision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "placement": { + "name": "placement", + "type": "formPlacement", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "lead_endpointId_endpoint_id_fk": { + "name": "lead_endpointId_endpoint_id_fk", + "tableFrom": "lead", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_formId_form_id_fk": { + "name": "lead_formId_form_id_fk", + "tableFrom": "lead", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log": { + "name": "log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "logType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "postType": { + "name": "postType", + "type": "logPostType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "log_endpointId_endpoint_id_fk": { + "name": "log_endpointId_endpoint_id_fk", + "tableFrom": "log", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usagePeriod": { + "name": "usagePeriod", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "periodStart": { + "name": "periodStart", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notifiedAt80": { + "name": "notifiedAt80", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifiedAt100": { + "name": "notifiedAt100", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "usagePeriod_userId_user_id_fk": { + "name": "usagePeriod_userId_user_id_fk", + "tableFrom": "usagePeriod", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "usagePeriod_userId_periodStart_pk": { + "name": "usagePeriod_userId_periodStart_pk", + "columns": [ + "userId", + "periodStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "plan": { + "name": "plan", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeCurrentPeriodEnd": { + "name": "stripeCurrentPeriodEnd", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripeCancelAtPeriodEnd": { + "name": "stripeCancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "legacyPriceMigrationRequired": { + "name": "legacyPriceMigrationRequired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wordpressConnection": { + "name": "wordpressConnection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteOrigin": { + "name": "siteOrigin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteName": { + "name": "siteName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wordpress_connection_token_hash_unique": { + "name": "wordpress_connection_token_hash_unique", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wordpress_connection_owner_site_idx": { + "name": "wordpress_connection_owner_site_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "siteOrigin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wordpressConnection_userId_user_id_fk": { + "name": "wordpressConnection_userId_user_id_fk", + "tableFrom": "wordpressConnection", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.formOriginKind": { + "name": "formOriginKind", + "schema": "public", + "values": [ + "embed", + "wordpress" + ] + }, + "public.formPlacement": { + "name": "formPlacement", + "schema": "public", + "values": [ + "headless", + "legacy_html", + "hosted", + "embed", + "wordpress" + ] + }, + "public.logPostType": { + "name": "logPostType", + "schema": "public", + "values": [ + "http", + "form", + "webhook", + "email" + ] + }, + "public.logType": { + "name": "logType", + "schema": "public", + "values": [ + "success", + "error" + ] + }, + "public.plan": { + "name": "plan", + "schema": "public", + "values": [ + "free", + "lite", + "pro", + "business", + "enterprise" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/drizzle/meta/0009_snapshot.json b/lib/db/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..7bc7f52 --- /dev/null +++ b/lib/db/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1195 @@ +{ + "id": "3ca0cbdb-c36b-4e7c-b97c-4092f62c29c2", + "prevId": "b73038f2-57b2-45ab-a526-850b29679d24", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.endpoint": { + "name": "endpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhookEnabled": { + "name": "webhookEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "emailNotify": { + "name": "emailNotify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook": { + "name": "webhook", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formEnabled": { + "name": "formEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "successUrl": { + "name": "successUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failUrl": { + "name": "failUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "endpoint_userId_user_id_fk": { + "name": "endpoint_userId_user_id_fk", + "tableFrom": "endpoint", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formOrigin": { + "name": "formOrigin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "formOriginKind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_origin_unique": { + "name": "form_origin_unique", + "columns": [ + { + "expression": "formId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formOrigin_formId_form_id_fk": { + "name": "formOrigin_formId_form_id_fk", + "tableFrom": "formOrigin", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "formOrigin_connectionId_wordpressConnection_id_fk": { + "name": "formOrigin_connectionId_wordpressConnection_id_fk", + "tableFrom": "formOrigin", + "tableTo": "wordpressConnection", + "columnsFrom": [ + "connectionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formRateBucket": { + "name": "formRateBucket", + "schema": "", + "columns": { + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucketKey": { + "name": "bucketKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windowStart": { + "name": "windowStart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_rate_bucket_prune_idx": { + "name": "form_rate_bucket_prune_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formRateBucket_formId_form_id_fk": { + "name": "formRateBucket_formId_form_id_fk", + "tableFrom": "formRateBucket", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "formRateBucket_formId_bucketKey_windowStart_pk": { + "name": "formRateBucket_formId_bucketKey_windowStart_pk", + "columns": [ + "formId", + "bucketKey", + "windowStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachedToExistingEndpoint": { + "name": "attachedToExistingEndpoint", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "draftDefinition": { + "name": "draftDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "draftRevision": { + "name": "draftRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "publishedDefinition": { + "name": "publishedDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishedRevision": { + "name": "publishedRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unpublishedAt": { + "name": "unpublishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_endpoint_unique": { + "name": "form_endpoint_unique", + "columns": [ + { + "expression": "endpointId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_public_id_unique": { + "name": "form_public_id_unique", + "columns": [ + { + "expression": "publicId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_owner_updated_idx": { + "name": "form_owner_updated_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_userId_user_id_fk": { + "name": "form_userId_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_endpointId_endpoint_id_fk": { + "name": "form_endpointId_endpoint_id_fk", + "tableFrom": "form", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead": { + "name": "lead", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formRevision": { + "name": "formRevision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "placement": { + "name": "placement", + "type": "formPlacement", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "lead_endpointId_endpoint_id_fk": { + "name": "lead_endpointId_endpoint_id_fk", + "tableFrom": "lead", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_formId_form_id_fk": { + "name": "lead_formId_form_id_fk", + "tableFrom": "lead", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log": { + "name": "log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "logType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "postType": { + "name": "postType", + "type": "logPostType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "log_endpointId_endpoint_id_fk": { + "name": "log_endpointId_endpoint_id_fk", + "tableFrom": "log", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usagePeriod": { + "name": "usagePeriod", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "periodStart": { + "name": "periodStart", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notifiedAt80": { + "name": "notifiedAt80", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifiedAt100": { + "name": "notifiedAt100", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "usagePeriod_userId_user_id_fk": { + "name": "usagePeriod_userId_user_id_fk", + "tableFrom": "usagePeriod", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "usagePeriod_userId_periodStart_pk": { + "name": "usagePeriod_userId_periodStart_pk", + "columns": [ + "userId", + "periodStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "plan": { + "name": "plan", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeCurrentPeriodEnd": { + "name": "stripeCurrentPeriodEnd", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripeCancelAtPeriodEnd": { + "name": "stripeCancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "legacyPriceMigrationRequired": { + "name": "legacyPriceMigrationRequired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wordpressConnection": { + "name": "wordpressConnection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteOrigin": { + "name": "siteOrigin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteName": { + "name": "siteName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wordpress_connection_token_hash_unique": { + "name": "wordpress_connection_token_hash_unique", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wordpress_connection_owner_site_idx": { + "name": "wordpress_connection_owner_site_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "siteOrigin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wordpressConnection_userId_user_id_fk": { + "name": "wordpressConnection_userId_user_id_fk", + "tableFrom": "wordpressConnection", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.formOriginKind": { + "name": "formOriginKind", + "schema": "public", + "values": [ + "embed", + "wordpress" + ] + }, + "public.formPlacement": { + "name": "formPlacement", + "schema": "public", + "values": [ + "headless", + "legacy_html", + "hosted", + "embed", + "wordpress" + ] + }, + "public.logPostType": { + "name": "logPostType", + "schema": "public", + "values": [ + "http", + "form", + "webhook", + "email" + ] + }, + "public.logType": { + "name": "logType", + "schema": "public", + "values": [ + "success", + "error" + ] + }, + "public.plan": { + "name": "plan", + "schema": "public", + "values": [ + "free", + "lite", + "pro", + "business", + "enterprise" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/drizzle/meta/0010_snapshot.json b/lib/db/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..2c7d73a --- /dev/null +++ b/lib/db/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1256 @@ +{ + "id": "3dac894e-9983-4349-be02-c957dd696ded", + "prevId": "3ca0cbdb-c36b-4e7c-b97c-4092f62c29c2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.endpoint": { + "name": "endpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhookEnabled": { + "name": "webhookEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "emailNotify": { + "name": "emailNotify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook": { + "name": "webhook", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formEnabled": { + "name": "formEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "successUrl": { + "name": "successUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failUrl": { + "name": "failUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "endpoint_userId_user_id_fk": { + "name": "endpoint_userId_user_id_fk", + "tableFrom": "endpoint", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formOrigin": { + "name": "formOrigin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "formOriginKind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_origin_unique": { + "name": "form_origin_unique", + "columns": [ + { + "expression": "formId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formOrigin_formId_form_id_fk": { + "name": "formOrigin_formId_form_id_fk", + "tableFrom": "formOrigin", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "formOrigin_connectionId_wordpressConnection_id_fk": { + "name": "formOrigin_connectionId_wordpressConnection_id_fk", + "tableFrom": "formOrigin", + "tableTo": "wordpressConnection", + "columnsFrom": [ + "connectionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formPlacementMilestone": { + "name": "formPlacementMilestone", + "schema": "", + "columns": { + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "placement": { + "name": "placement", + "type": "formPlacement", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "firstLeadId": { + "name": "firstLeadId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "formPlacementMilestone_formId_form_id_fk": { + "name": "formPlacementMilestone_formId_form_id_fk", + "tableFrom": "formPlacementMilestone", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "formPlacementMilestone_formId_placement_pk": { + "name": "formPlacementMilestone_formId_placement_pk", + "columns": [ + "formId", + "placement" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formRateBucket": { + "name": "formRateBucket", + "schema": "", + "columns": { + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucketKey": { + "name": "bucketKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windowStart": { + "name": "windowStart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_rate_bucket_prune_idx": { + "name": "form_rate_bucket_prune_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formRateBucket_formId_form_id_fk": { + "name": "formRateBucket_formId_form_id_fk", + "tableFrom": "formRateBucket", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "formRateBucket_formId_bucketKey_windowStart_pk": { + "name": "formRateBucket_formId_bucketKey_windowStart_pk", + "columns": [ + "formId", + "bucketKey", + "windowStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachedToExistingEndpoint": { + "name": "attachedToExistingEndpoint", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "draftDefinition": { + "name": "draftDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "draftRevision": { + "name": "draftRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "publishedDefinition": { + "name": "publishedDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishedRevision": { + "name": "publishedRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unpublishedAt": { + "name": "unpublishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_endpoint_unique": { + "name": "form_endpoint_unique", + "columns": [ + { + "expression": "endpointId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_public_id_unique": { + "name": "form_public_id_unique", + "columns": [ + { + "expression": "publicId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_owner_updated_idx": { + "name": "form_owner_updated_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_userId_user_id_fk": { + "name": "form_userId_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_endpointId_endpoint_id_fk": { + "name": "form_endpointId_endpoint_id_fk", + "tableFrom": "form", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead": { + "name": "lead", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formRevision": { + "name": "formRevision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "placement": { + "name": "placement", + "type": "formPlacement", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "lead_endpointId_endpoint_id_fk": { + "name": "lead_endpointId_endpoint_id_fk", + "tableFrom": "lead", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_formId_form_id_fk": { + "name": "lead_formId_form_id_fk", + "tableFrom": "lead", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log": { + "name": "log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "logType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "postType": { + "name": "postType", + "type": "logPostType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "log_endpointId_endpoint_id_fk": { + "name": "log_endpointId_endpoint_id_fk", + "tableFrom": "log", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usagePeriod": { + "name": "usagePeriod", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "periodStart": { + "name": "periodStart", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notifiedAt80": { + "name": "notifiedAt80", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifiedAt100": { + "name": "notifiedAt100", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "usagePeriod_userId_user_id_fk": { + "name": "usagePeriod_userId_user_id_fk", + "tableFrom": "usagePeriod", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "usagePeriod_userId_periodStart_pk": { + "name": "usagePeriod_userId_periodStart_pk", + "columns": [ + "userId", + "periodStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "plan": { + "name": "plan", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeCurrentPeriodEnd": { + "name": "stripeCurrentPeriodEnd", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripeCancelAtPeriodEnd": { + "name": "stripeCancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "legacyPriceMigrationRequired": { + "name": "legacyPriceMigrationRequired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wordpressConnection": { + "name": "wordpressConnection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteOrigin": { + "name": "siteOrigin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteName": { + "name": "siteName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wordpress_connection_token_hash_unique": { + "name": "wordpress_connection_token_hash_unique", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wordpress_connection_owner_site_idx": { + "name": "wordpress_connection_owner_site_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "siteOrigin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wordpressConnection_userId_user_id_fk": { + "name": "wordpressConnection_userId_user_id_fk", + "tableFrom": "wordpressConnection", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.formOriginKind": { + "name": "formOriginKind", + "schema": "public", + "values": [ + "embed", + "wordpress" + ] + }, + "public.formPlacement": { + "name": "formPlacement", + "schema": "public", + "values": [ + "headless", + "legacy_html", + "hosted", + "embed", + "wordpress" + ] + }, + "public.logPostType": { + "name": "logPostType", + "schema": "public", + "values": [ + "http", + "form", + "webhook", + "email" + ] + }, + "public.logType": { + "name": "logType", + "schema": "public", + "values": [ + "success", + "error" + ] + }, + "public.plan": { + "name": "plan", + "schema": "public", + "values": [ + "free", + "lite", + "pro", + "business", + "enterprise" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/drizzle/meta/_journal.json b/lib/db/drizzle/meta/_journal.json index 5b5a1e2..5431b58 100644 --- a/lib/db/drizzle/meta/_journal.json +++ b/lib/db/drizzle/meta/_journal.json @@ -43,6 +43,41 @@ "when": 1735599989929, "tag": "0005_fine_sersi", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1788298643984, + "tag": "0006_router_forms_mvp", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1788298745208, + "tag": "0007_form_attachment_provenance", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1788300072107, + "tag": "0008_stripe_migration_state", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1788300476416, + "tag": "0009_form_origin_kind_uniqueness", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1788300568939, + "tag": "0010_placement_first_lead_analytics", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/index.ts b/lib/db/index.ts index d6fc5aa..19e68ea 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -2,7 +2,18 @@ import { InferSelectModel, InferInsertModel } from "drizzle-orm"; import { sql } from "@vercel/postgres"; import { drizzle } from "drizzle-orm/vercel-postgres"; -import { users, endpoints, logs, leads } from "./schema"; +import { + users, + endpoints, + logs, + leads, + forms, + formOrigins, + wordpressConnections, + usagePeriods, + formRateBuckets, + formPlacementMilestones, +} from "./schema"; export type User = InferSelectModel; export type NewUser = InferInsertModel; @@ -16,4 +27,15 @@ export type NewLog = InferInsertModel; export type Lead = InferSelectModel; export type NewLead = InferInsertModel; +export type Form = InferSelectModel; +export type NewForm = InferInsertModel; + +export type FormOrigin = InferSelectModel; +export type WordPressConnection = InferSelectModel; +export type UsagePeriod = InferSelectModel; +export type FormRateBucket = InferSelectModel; +export type FormPlacementMilestone = InferSelectModel< + typeof formPlacementMilestones +>; + export const db = drizzle(sql); diff --git a/lib/db/migrate.ts b/lib/db/migrate.ts index 51d79f4..e15b7bb 100644 --- a/lib/db/migrate.ts +++ b/lib/db/migrate.ts @@ -1,6 +1,7 @@ import { loadEnvConfig } from "@next/env"; -import { migrate } from "drizzle-orm/vercel-postgres/migrator"; -import { db } from "."; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { Pool } from "pg"; /** * Migration function @@ -8,15 +9,23 @@ import { db } from "."; * Only runs when the NODE_ENV is NOT production */ async function main() { + let pool: Pool | undefined; try { const dev = process.env.NODE_ENV !== "production"; loadEnvConfig("./", dev); + if (!process.env.POSTGRES_URL) { + throw new Error("POSTGRES_URL is not configured."); + } - await migrate(db, { migrationsFolder: "lib/db/drizzle" }); + pool = new Pool({ connectionString: process.env.POSTGRES_URL }); + await migrate(drizzle(pool), { migrationsFolder: "lib/db/drizzle" }); console.log("Migrations complete"); } catch (error) { console.log("Migrations failed"); console.error(error); + process.exitCode = 1; + } finally { + await pool?.end(); } } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 9ac69ca..137b1c1 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -7,14 +7,23 @@ import { pgEnum, boolean, jsonb, + date, + uniqueIndex, + index, } from "drizzle-orm/pg-core"; import type { AdapterAccount } from "@auth/core/adapters"; import { init } from "@paralleldrive/cuid2"; +import type { FormDefinitionV1 } from "@/lib/forms/definition"; +import type { CompatibleEndpointField } from "@/lib/forms/endpoint-schema"; const createId = init({ length: 8, }); +const createPublicId = init({ + length: 14, +}); + export const planEnum = pgEnum("plan", [ "free", "lite", @@ -35,6 +44,17 @@ export const users = pgTable("user", { leadCount: integer("leadCount").notNull().default(0), plan: planEnum("plan").notNull().default("free"), stripeCustomerId: text("stripeCustomerId"), + stripeSubscriptionId: text("stripeSubscriptionId"), + stripeSubscriptionStatus: text("stripeSubscriptionStatus"), + stripeCurrentPeriodEnd: timestamp("stripeCurrentPeriodEnd", { + withTimezone: true, + }), + stripeCancelAtPeriodEnd: boolean("stripeCancelAtPeriodEnd") + .notNull() + .default(false), + legacyPriceMigrationRequired: boolean("legacyPriceMigrationRequired") + .notNull() + .default(false), createdAt: timestamp("createdAt", { withTimezone: true }) .notNull() .defaultNow(), @@ -94,7 +114,7 @@ export const endpoints = pgTable("endpoint", { .references(() => users.id, { onDelete: "cascade" }), name: text("name").notNull(), schema: jsonb("schema") - .$type<{ key: string; value: ValidationType }[]>() + .$type() .notNull(), enabled: boolean("enabled").default(true).notNull(), webhookEnabled: boolean("webhookEnabled").default(false).notNull(), @@ -108,6 +128,169 @@ export const endpoints = pgTable("endpoint", { updatedAt: timestamp("updatedAt", { mode: "date" }).notNull(), }); +export const forms = pgTable( + "form", + { + id: text("id") + .$defaultFn(() => createId()) + .notNull() + .primaryKey(), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + endpointId: text("endpointId") + .notNull() + .references(() => endpoints.id, { onDelete: "restrict" }), + publicId: text("publicId") + .$defaultFn(() => createPublicId()) + .notNull(), + name: text("name").notNull(), + attachedToExistingEndpoint: boolean("attachedToExistingEndpoint") + .notNull() + .default(false), + draftDefinition: jsonb("draftDefinition") + .$type() + .notNull(), + draftRevision: integer("draftRevision").notNull().default(1), + publishedDefinition: jsonb("publishedDefinition").$type(), + publishedRevision: integer("publishedRevision").notNull().default(0), + publishedAt: timestamp("publishedAt", { withTimezone: true }), + unpublishedAt: timestamp("unpublishedAt", { withTimezone: true }), + createdAt: timestamp("createdAt", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updatedAt", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (form) => ({ + endpointUnique: uniqueIndex("form_endpoint_unique").on(form.endpointId), + publicIdUnique: uniqueIndex("form_public_id_unique").on(form.publicId), + ownerUpdatedIndex: index("form_owner_updated_idx").on( + form.userId, + form.updatedAt + ), + }) +); + +export const formOriginKindEnum = pgEnum("formOriginKind", [ + "embed", + "wordpress", +]); + +export const wordpressConnections = pgTable( + "wordpressConnection", + { + id: text("id") + .$defaultFn(() => createId()) + .notNull() + .primaryKey(), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + siteOrigin: text("siteOrigin").notNull(), + siteName: text("siteName"), + tokenPrefix: text("tokenPrefix").notNull(), + tokenHash: text("tokenHash").notNull(), + lastUsedAt: timestamp("lastUsedAt", { withTimezone: true }), + revokedAt: timestamp("revokedAt", { withTimezone: true }), + createdAt: timestamp("createdAt", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updatedAt", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (connection) => ({ + tokenHashUnique: uniqueIndex("wordpress_connection_token_hash_unique").on( + connection.tokenHash + ), + ownerSiteIndex: index("wordpress_connection_owner_site_idx").on( + connection.userId, + connection.siteOrigin + ), + }) +); + +export const formOrigins = pgTable( + "formOrigin", + { + id: text("id") + .$defaultFn(() => createId()) + .notNull() + .primaryKey(), + formId: text("formId") + .notNull() + .references(() => forms.id, { onDelete: "cascade" }), + connectionId: text("connectionId").references( + () => wordpressConnections.id, + { onDelete: "cascade" } + ), + origin: text("origin").notNull(), + kind: formOriginKindEnum("kind").notNull(), + createdAt: timestamp("createdAt", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (formOrigin) => ({ + formOriginUnique: uniqueIndex("form_origin_unique").on( + formOrigin.formId, + formOrigin.origin, + formOrigin.kind + ), + }) +); + +export const usagePeriods = pgTable( + "usagePeriod", + { + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + periodStart: date("periodStart", { mode: "string" }).notNull(), + leadCount: integer("leadCount").notNull().default(0), + notifiedAt80: timestamp("notifiedAt80", { withTimezone: true }), + notifiedAt100: timestamp("notifiedAt100", { withTimezone: true }), + updatedAt: timestamp("updatedAt", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (usagePeriod) => ({ + compoundKey: primaryKey({ + columns: [usagePeriod.userId, usagePeriod.periodStart], + }), + }) +); + +export const formRateBuckets = pgTable( + "formRateBucket", + { + formId: text("formId") + .notNull() + .references(() => forms.id, { onDelete: "cascade" }), + bucketKey: text("bucketKey").notNull(), + windowStart: timestamp("windowStart", { withTimezone: true }).notNull(), + attempts: integer("attempts").notNull().default(0), + updatedAt: timestamp("updatedAt", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (bucket) => ({ + compoundKey: primaryKey({ + columns: [bucket.formId, bucket.bucketKey, bucket.windowStart], + }), + pruneIndex: index("form_rate_bucket_prune_idx").on(bucket.updatedAt), + }) +); + +export const formPlacementEnum = pgEnum("formPlacement", [ + "headless", + "legacy_html", + "hosted", + "embed", + "wordpress", +]); + export const leads = pgTable("lead", { id: text("id") .$defaultFn(() => createId()) @@ -115,12 +298,34 @@ export const leads = pgTable("lead", { .primaryKey(), endpointId: text("endpointId") .notNull() - .references(() => endpoints.id, { onDelete: "cascade" }), + .references(() => endpoints.id, { onDelete: "cascade" }), + formId: text("formId").references(() => forms.id, { onDelete: "set null" }), + formRevision: integer("formRevision"), + placement: formPlacementEnum("placement"), data: jsonb("data").$type<{ [key: string]: any }>().notNull(), createdAt: timestamp("createdAt", { mode: "date" }).notNull(), updatedAt: timestamp("updatedAt", { mode: "date" }).notNull(), }); +export const formPlacementMilestones = pgTable( + "formPlacementMilestone", + { + formId: text("formId") + .notNull() + .references(() => forms.id, { onDelete: "cascade" }), + placement: formPlacementEnum("placement").notNull(), + firstLeadId: text("firstLeadId").notNull(), + createdAt: timestamp("createdAt", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (milestone) => ({ + compoundKey: primaryKey({ + columns: [milestone.formId, milestone.placement], + }), + }) +); + export const logTypeEnum = pgEnum("logType", ["success", "error"]); export const logPostTypeEnum = pgEnum("logPostType", [ "http", diff --git a/lib/forms/cache.ts b/lib/forms/cache.ts new file mode 100644 index 0000000..955271d --- /dev/null +++ b/lib/forms/cache.ts @@ -0,0 +1,8 @@ +import { revalidateTag } from "next/cache"; + +export const publishedFormCacheTag = (publicId: string) => + `published-form:${publicId}`; + +export function invalidatePublishedForm(publicId: string): void { + revalidateTag(publishedFormCacheTag(publicId)); +} diff --git a/lib/forms/definition.ts b/lib/forms/definition.ts new file mode 100644 index 0000000..91a6bd1 --- /dev/null +++ b/lib/forms/definition.ts @@ -0,0 +1,424 @@ +import { z } from "zod"; +import validator from "validator"; + +const fieldIdSchema = z + .string() + .min(1) + .max(80) + .regex(/^[A-Za-z][A-Za-z0-9_-]*$/, "Use a stable alphanumeric field ID."); + +const submissionKeySchema = z + .string() + .min(1) + .max(80) + .regex( + /^[A-Za-z][A-Za-z0-9_]*$/, + "Submission keys must start with a letter and contain only letters, numbers, and underscores." + ); + +const optionSchema = z.object({ + id: fieldIdSchema, + label: z.string().trim().min(1).max(120), + value: z.string().trim().min(1).max(120), +}); + +const baseFieldShape = { + id: fieldIdSchema, + key: submissionKeySchema, + label: z.string().trim().min(1).max(160), + helpText: z.string().trim().max(500).optional(), + required: z.boolean().default(false), +}; + +const textValidationSchema = z + .object({ + minLength: z.number().int().min(0).max(10_000).optional(), + maxLength: z.number().int().min(1).max(10_000).optional(), + }) + .refine( + (value) => + value.minLength === undefined || + value.maxLength === undefined || + value.minLength <= value.maxLength, + { message: "Minimum length cannot exceed maximum length." } + ); + +const numberValidationSchema = z + .object({ + min: z.number().finite().optional(), + max: z.number().finite().optional(), + step: z.number().positive().finite().optional(), + }) + .refine( + (value) => + value.min === undefined || value.max === undefined || value.min <= value.max, + { message: "Minimum cannot exceed maximum." } + ); + +const dateValidationSchema = z + .object({ + min: z.string().date().optional(), + max: z.string().date().optional(), + }) + .refine( + (value) => + value.min === undefined || value.max === undefined || value.min <= value.max, + { message: "Minimum date cannot exceed maximum date." } + ); + +const stringField = (kind: "text" | "email" | "phone" | "url") => + z.object({ + ...baseFieldShape, + kind: z.literal(kind), + placeholder: z.string().max(200).optional(), + defaultValue: z.string().max(10_000).optional(), + validation: textValidationSchema.optional(), + }); + +const textareaField = z.object({ + ...baseFieldShape, + kind: z.literal("textarea"), + placeholder: z.string().max(200).optional(), + defaultValue: z.string().max(10_000).optional(), + rows: z.number().int().min(2).max(20).optional(), + validation: textValidationSchema.optional(), +}); + +const numberField = (kind: "number" | "slider") => + z.object({ + ...baseFieldShape, + kind: z.literal(kind), + placeholder: z.string().max(200).optional(), + defaultValue: z.number().finite().optional(), + validation: numberValidationSchema.optional(), + }); + +const dateField = z.object({ + ...baseFieldShape, + kind: z.literal("date"), + defaultValue: z.string().date().optional(), + validation: dateValidationSchema.optional(), +}); + +const choiceField = (kind: "select" | "radio") => + z.object({ + ...baseFieldShape, + kind: z.literal(kind), + placeholder: z.string().max(200).optional(), + defaultValue: z.string().max(120).optional(), + options: z.array(optionSchema).min(1).max(100), + }); + +const checkboxGroupField = z.object({ + ...baseFieldShape, + kind: z.literal("checkbox-group"), + defaultValue: z.array(z.string().max(120)).max(100).optional(), + options: z.array(optionSchema).min(1).max(100), + validation: z + .object({ + minSelections: z.number().int().min(0).max(100).optional(), + maxSelections: z.number().int().min(1).max(100).optional(), + }) + .refine( + (value) => + value.minSelections === undefined || + value.maxSelections === undefined || + value.minSelections <= value.maxSelections, + { message: "Minimum selections cannot exceed maximum selections." } + ) + .optional(), +}); + +const booleanField = (kind: "checkbox" | "yes-no" | "switch") => + z.object({ + ...baseFieldShape, + kind: z.literal(kind), + defaultValue: z.boolean().optional(), + }); + +export const formFieldV1Schema = z.discriminatedUnion("kind", [ + stringField("text"), + stringField("email"), + stringField("phone"), + stringField("url"), + dateField, + numberField("number"), + textareaField, + choiceField("select"), + choiceField("radio"), + booleanField("checkbox"), + checkboxGroupField, + booleanField("yes-no"), + booleanField("switch"), + numberField("slider"), +]); + +const completionSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("message"), + message: z.string().trim().min(1).max(1_000), + }), + z.object({ + type: z.literal("redirect"), + url: z + .string() + .url() + .refine((value) => new URL(value).protocol === "https:", { + message: "Redirect URLs must use HTTPS.", + }), + }), +]); + +export const formDefinitionV1Schema = z + .object({ + version: z.literal(1), + title: z.string().trim().min(1).max(120), + description: z.string().trim().max(600).optional(), + fields: z.array(formFieldV1Schema).max(100), + submitLabel: z.string().trim().min(1).max(80), + completion: completionSchema, + }) + .superRefine((definition, context) => { + const ids = new Set(); + const keys = new Set(); + + definition.fields.forEach((field, index) => { + if (ids.has(field.id)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["fields", index, "id"], + message: "Field IDs must be unique.", + }); + } + if (keys.has(field.key)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["fields", index, "key"], + message: "Submission keys must be unique.", + }); + } + ids.add(field.id); + keys.add(field.key); + + if ("options" in field) { + const optionIds = new Set(); + const optionValues = new Set(); + field.options.forEach((option, optionIndex) => { + if (optionIds.has(option.id) || optionValues.has(option.value)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["fields", index, "options", optionIndex], + message: "Option IDs and values must be unique within a field.", + }); + } + optionIds.add(option.id); + optionValues.add(option.value); + }); + } + }); + }); + +export type FormDefinitionV1 = z.infer; +export type FormFieldV1 = z.infer; +export type FormCompletionV1 = z.infer; + +export type CompiledEndpointField = { + key: string; + value: + | "phone" + | "email" + | "string" + | "number" + | "date" + | "boolean" + | "url" + | "zip_code" + | "string_array"; + required: boolean; + constraints?: { + minLength?: number; + maxLength?: number; + min?: number | string; + max?: number | string; + step?: number; + allowedValues?: string[]; + minItems?: number; + maxItems?: number; + }; +}; + +export function compileEndpointSchema( + input: FormDefinitionV1 +): CompiledEndpointField[] { + const definition = formDefinitionV1Schema.parse(input); + + return definition.fields.map((field): CompiledEndpointField => { + const base = { key: field.key, required: field.required }; + + switch (field.kind) { + case "email": + case "phone": + case "url": + return { + ...base, + value: field.kind, + ...(field.validation ? { constraints: field.validation } : {}), + }; + case "text": + case "textarea": + return { + ...base, + value: "string", + ...(field.validation ? { constraints: field.validation } : {}), + }; + case "date": + return { + ...base, + value: "date", + ...(field.validation ? { constraints: field.validation } : {}), + }; + case "number": + case "slider": + return { + ...base, + value: "number", + ...(field.validation ? { constraints: field.validation } : {}), + }; + case "select": + case "radio": + return { + ...base, + value: "string", + constraints: { allowedValues: field.options.map((option) => option.value) }, + }; + case "checkbox-group": + return { + ...base, + value: "string_array", + constraints: { + allowedValues: field.options.map((option) => option.value), + ...(field.validation?.minSelections !== undefined + ? { minItems: field.validation.minSelections } + : {}), + ...(field.validation?.maxSelections !== undefined + ? { maxItems: field.validation.maxSelections } + : {}), + }, + }; + case "checkbox": + case "yes-no": + case "switch": + return { ...base, value: "boolean" }; + } + }); +} + +type FieldErrors = Record; + +export type FormValuesResult = + | { success: true; data: Record } + | { success: false; errors: FieldErrors }; + +function optionalString(schema: z.ZodType, required: boolean) { + return z.preprocess( + (value) => (typeof value === "string" ? value.trim() : value), + required + ? schema.refine((value) => value.length > 0, "This field is required.") + : schema.optional() + ); +} + +function schemaForField(field: FormFieldV1): z.ZodTypeAny { + switch (field.kind) { + case "text": + case "textarea": { + let schema = z.string(); + if (field.validation?.minLength !== undefined) { + schema = schema.min(field.validation.minLength, `Enter at least ${field.validation.minLength} characters.`); + } + if (field.validation?.maxLength !== undefined) { + schema = schema.max(field.validation.maxLength, `Enter no more than ${field.validation.maxLength} characters.`); + } + return optionalString(schema, field.required); + } + case "email": + return optionalString(z.string().email("Enter a valid email address."), field.required); + case "phone": + return optionalString( + z.string().refine((value) => validator.isMobilePhone(value), "Enter a valid phone number."), + field.required + ); + case "url": + return optionalString(z.string().url("Enter a valid URL."), field.required); + case "date": { + let schema: z.ZodType = z.string().date("Enter a valid date."); + if (field.validation?.min) { + schema = schema.refine((value) => value >= field.validation!.min!, `Choose ${field.validation.min} or later.`); + } + if (field.validation?.max) { + schema = schema.refine((value) => value <= field.validation!.max!, `Choose ${field.validation.max} or earlier.`); + } + return optionalString(schema, field.required); + } + case "number": + case "slider": { + let schema = z.coerce.number().finite("Enter a valid number."); + if (field.validation?.min !== undefined) schema = schema.min(field.validation.min); + if (field.validation?.max !== undefined) schema = schema.max(field.validation.max); + return field.required ? schema : z.preprocess((value) => (value === "" ? undefined : value), schema.optional()); + } + case "select": + case "radio": { + const allowed = new Set(field.options.map((option) => option.value)); + const schema = z.string().refine((value) => allowed.has(value), "Choose a valid option."); + return optionalString(schema, field.required); + } + case "checkbox-group": { + const allowed = new Set(field.options.map((option) => option.value)); + const minimum = field.required ? Math.max(1, field.validation?.minSelections ?? 0) : field.validation?.minSelections; + let schema = z + .array(z.string()) + .min( + minimum ?? 0, + minimum + ? `Choose at least ${minimum} option${minimum === 1 ? "" : "s"}.` + : undefined + ) + .max(field.validation?.maxSelections ?? field.options.length) + .refine((values) => values.every((value) => allowed.has(value)), "Choose only valid options."); + return field.required ? schema : schema.optional(); + } + case "checkbox": + return field.required + ? z.literal(true, { errorMap: () => ({ message: "This field is required." }) }) + : z.boolean().optional(); + case "yes-no": + case "switch": + return field.required ? z.boolean() : z.boolean().optional(); + } +} + +export function validateFormValues( + input: FormDefinitionV1, + values: unknown +): FormValuesResult { + const definition = formDefinitionV1Schema.parse(input); + const shape = Object.fromEntries( + definition.fields.map((field) => [field.key, schemaForField(field)]) + ); + const result = z.object(shape).strict("Unknown field.").safeParse(values); + + if (result.success) return { success: true, data: result.data }; + + const errors: FieldErrors = {}; + for (const issue of result.error.issues) { + if (issue.code === z.ZodIssueCode.unrecognized_keys) { + for (const key of issue.keys) errors[key] = ["Unknown field."]; + continue; + } + const key = String(issue.path[0] ?? "form"); + errors[key] = [...(errors[key] ?? []), issue.message]; + } + + return { success: false, errors }; +} diff --git a/lib/forms/endpoint-schema.ts b/lib/forms/endpoint-schema.ts new file mode 100644 index 0000000..887b0e6 --- /dev/null +++ b/lib/forms/endpoint-schema.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; +import validator from "validator"; +import type { CompiledEndpointField } from "./definition"; + +export type LegacyEndpointField = { + key: string; + value: + | "phone" + | "email" + | "string" + | "number" + | "date" + | "boolean" + | "url" + | "zip_code" + | "string_array"; + required?: boolean; +}; + +export type CompatibleEndpointField = CompiledEndpointField | LegacyEndpointField; + +export type EndpointValuesResult = + | { success: true; data: Record } + | { success: false; errors: Record }; + +function isLegacyField(field: CompatibleEndpointField): boolean { + return !("required" in field) || field.required === undefined; +} + +function fieldSchema(field: CompatibleEndpointField): z.ZodTypeAny { + const required = isLegacyField(field) ? true : field.required; + const constraints = "constraints" in field ? field.constraints : undefined; + let schema: z.ZodTypeAny; + + switch (field.value) { + case "email": + schema = z.string().email("Not a valid email."); + break; + case "phone": + schema = z + .string() + .refine((value) => validator.isMobilePhone(value), "Not a valid phone number."); + break; + case "url": + schema = z.string().url("Not a valid URL."); + break; + case "zip_code": + schema = z.string().length(5, "Not a valid zip code."); + break; + case "date": { + let dateSchema: z.ZodType = z.string().date("Not a valid date."); + if (typeof constraints?.min === "string") { + dateSchema = dateSchema.refine((value) => value >= constraints.min!, "Date is too early."); + } + if (typeof constraints?.max === "string") { + dateSchema = dateSchema.refine((value) => value <= constraints.max!, "Date is too late."); + } + schema = dateSchema; + break; + } + case "number": { + let numberSchema = z.number().finite(); + if (typeof constraints?.min === "number") numberSchema = numberSchema.min(constraints.min); + if (typeof constraints?.max === "number") numberSchema = numberSchema.max(constraints.max); + schema = numberSchema; + break; + } + case "boolean": + schema = z.boolean(); + break; + case "string_array": { + let arraySchema = z.array(z.string()); + if (constraints?.minItems !== undefined) arraySchema = arraySchema.min(constraints.minItems); + if (constraints?.maxItems !== undefined) arraySchema = arraySchema.max(constraints.maxItems); + schema = constraints?.allowedValues + ? arraySchema.refine( + (values) => values.every((value) => constraints.allowedValues!.includes(value)), + "Contains an invalid option." + ) + : arraySchema; + break; + } + case "string": + default: { + let stringSchema = z.string(); + if (constraints?.minLength !== undefined) stringSchema = stringSchema.min(constraints.minLength); + else if (isLegacyField(field)) stringSchema = stringSchema.min(2, "Not a valid string."); + if (constraints?.maxLength !== undefined) stringSchema = stringSchema.max(constraints.maxLength); + if (constraints?.allowedValues) { + schema = stringSchema.refine( + (value) => constraints.allowedValues!.includes(value), + "Choose a valid option." + ); + } else { + schema = stringSchema; + } + } + } + + return required ? schema : schema.optional(); +} + +export function validateEndpointValues( + schema: CompatibleEndpointField[], + values: unknown, + options: { rejectUnknown?: boolean } = {} +): EndpointValuesResult { + const shape = Object.fromEntries( + schema.map((field) => [field.key, fieldSchema(field)]) + ); + const objectSchema = options.rejectUnknown + ? z.object(shape).strict("Unknown field.") + : z.object(shape); + const result = objectSchema.safeParse(values); + + if (result.success) return { success: true, data: result.data }; + + const errors: Record = {}; + for (const issue of result.error.issues) { + if (issue.code === z.ZodIssueCode.unrecognized_keys) { + for (const key of issue.keys) errors[key] = ["Unknown field."]; + continue; + } + const key = String(issue.path[0] ?? "form"); + errors[key] = [...(errors[key] ?? []), issue.message]; + } + return { success: false, errors }; +} diff --git a/lib/forms/entitlements.ts b/lib/forms/entitlements.ts new file mode 100644 index 0000000..0d02b54 --- /dev/null +++ b/lib/forms/entitlements.ts @@ -0,0 +1,72 @@ +export type RouterPlan = "free" | "lite" | "pro" | "business" | "enterprise"; + +export type Entitlement = { + monthlyPrice: number | null; + annualPrice: number | null; + monthlyLeads: number | null; + showAttribution: boolean; +}; + +export const ENTITLEMENTS: Record = { + free: { + monthlyPrice: 0, + annualPrice: null, + monthlyLeads: 100, + showAttribution: true, + }, + // Existing Lite subscriptions retain this allowance until their current term ends. + lite: { + monthlyPrice: 7, + annualPrice: null, + monthlyLeads: 1_000, + showAttribution: false, + }, + pro: { + monthlyPrice: 19, + annualPrice: 190, + monthlyLeads: 10_000, + showAttribution: false, + }, + business: { + monthlyPrice: 49, + annualPrice: 490, + monthlyLeads: 50_000, + showAttribution: false, + }, + enterprise: { + monthlyPrice: null, + annualPrice: null, + monthlyLeads: null, + showAttribution: false, + }, +}; + +export const getEntitlement = (plan: RouterPlan): Entitlement => + ENTITLEMENTS[plan] ?? ENTITLEMENTS.free; + +export type CapacityState = { + state: "ok" | "warning" | "grace" | "paused"; + accepts: boolean; + used: number; + limit: number | null; + graceLimit: number | null; +}; + +export function getCapacityState(plan: RouterPlan, used: number): CapacityState { + const limit = getEntitlement(plan).monthlyLeads; + if (limit === null) { + return { state: "ok", accepts: true, used, limit: null, graceLimit: null }; + } + + const graceLimit = Math.round(limit * 1.1); + if (used >= graceLimit) { + return { state: "paused", accepts: false, used, limit, graceLimit }; + } + if (used >= limit) { + return { state: "grace", accepts: true, used, limit, graceLimit }; + } + if (used >= Math.ceil(limit * 0.8)) { + return { state: "warning", accepts: true, used, limit, graceLimit }; + } + return { state: "ok", accepts: true, used, limit, graceLimit }; +} diff --git a/lib/forms/feature-flags.ts b/lib/forms/feature-flags.ts new file mode 100644 index 0000000..b7546de --- /dev/null +++ b/lib/forms/feature-flags.ts @@ -0,0 +1,7 @@ +export function formsNavigationEnabled(): boolean { + return process.env.FORMS_NAV_ENABLED === "true"; +} + +export function publicFormsEnabled(): boolean { + return process.env.FORMS_PUBLIC_ENABLED !== "false"; +} diff --git a/lib/forms/lead-acceptance.ts b/lib/forms/lead-acceptance.ts new file mode 100644 index 0000000..b54eeb3 --- /dev/null +++ b/lib/forms/lead-acceptance.ts @@ -0,0 +1,368 @@ +import { and, eq, gte, isNotNull, isNull, sql } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/lib/db"; +import { + endpoints, + forms, + formPlacementMilestones, + leads, + logs, + usagePeriods, + users, +} from "@/lib/db/schema"; +import { captureServerEvent } from "@/lib/analytics/server"; +import { formDefinitionV1Schema, validateFormValues } from "./definition"; +import { validateEndpointValues } from "./endpoint-schema"; +import { + getCapacityState, + getEntitlement, + type CapacityState, + type RouterPlan, +} from "./entitlements"; +import type { FormPlacement } from "./submission-token"; +import { + crossedUsageThresholds, + sendUsageThresholdNotification, + type UsageThreshold, +} from "./usage-notifications"; + +export class LeadValidationError extends Error { + constructor(readonly fieldErrors: Record) { + super("The submitted values are invalid."); + this.name = "LeadValidationError"; + } +} + +export class LeadCapacityError extends Error { + constructor(readonly capacity: CapacityState) { + super("Monthly lead capacity has been reached."); + this.name = "LeadCapacityError"; + } +} + +export class LeadEndpointError extends Error { + constructor( + message: string, + readonly status: 403 | 404 = 404 + ) { + super(message); + this.name = "LeadEndpointError"; + } +} + +type HeadlessAcceptanceInput = { + endpointId: string; + values: unknown; + placement: "headless" | "legacy_html"; +}; + +type PublicFormAcceptanceInput = { + publicId: string; + values: unknown; + placement: FormPlacement; +}; + +export type AcceptLeadInput = HeadlessAcceptanceInput | PublicFormAcceptanceInput; + +type AcceptanceResult = { + leadId: string; + completion?: + | { type: "message"; message: string } + | { type: "redirect"; url: string }; + capacity: CapacityState; +}; + +function utcPeriodStart(now: Date): string { + return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-01`; +} + +async function deliverWebhook(input: { + endpointId: string; + url: string; + values: Record; +}): Promise { + try { + const response = await fetch(input.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input.values), + signal: AbortSignal.timeout(3_000), + }); + if (!response.ok) { + const message = (await response.text()).slice(0, 2_000) || `HTTP ${response.status}`; + await db.insert(logs).values({ + endpointId: input.endpointId, + type: "error", + postType: "webhook", + message: { error: message }, + createdAt: new Date(), + }); + return; + } + await db.insert(logs).values({ + endpointId: input.endpointId, + type: "success", + postType: "webhook", + message: { success: true, url: input.url }, + createdAt: new Date(), + }); + } catch (error) { + await db.insert(logs).values({ + endpointId: input.endpointId, + type: "error", + postType: "webhook", + message: { + error: error instanceof Error ? error.message.slice(0, 2_000) : "Webhook failed.", + }, + createdAt: new Date(), + }); + } +} + +async function logRejectedLead( + input: AcceptLeadInput, + error: unknown, + now: Date +): Promise { + try { + let endpointId: string | undefined; + if ("endpointId" in input) { + endpointId = input.endpointId; + } else { + const [row] = await db + .select({ endpointId: forms.endpointId }) + .from(forms) + .where(eq(forms.publicId, input.publicId)) + .limit(1); + endpointId = row?.endpointId; + } + if (!endpointId) return; + await db.insert(logs).values({ + endpointId, + type: "error", + postType: "publicId" in input ? "form" : "http", + message: { + error: + error instanceof LeadValidationError + ? "validation_failed" + : error instanceof LeadCapacityError + ? "monthly_capacity_reached" + : error instanceof Error + ? error.name + : "unknown_error", + ...(error instanceof LeadValidationError + ? { fields: Object.keys(error.fieldErrors) } + : {}), + }, + createdAt: now, + }); + } catch (loggingError) { + console.error("Could not record rejected lead attempt:", loggingError); + } +} + +export async function acceptLead( + input: AcceptLeadInput, + now = new Date() +): Promise { + try { + const accepted = await db.transaction(async (tx) => { + const publicSubmission = "publicId" in input; + const [row] = publicSubmission + ? await tx + .select({ + endpoint: endpoints, + owner: users, + form: forms, + }) + .from(forms) + .innerJoin(endpoints, eq(forms.endpointId, endpoints.id)) + .innerJoin(users, eq(forms.userId, users.id)) + .where( + and( + eq(forms.publicId, input.publicId), + isNotNull(forms.publishedAt) + ) + ) + .limit(1) + : await tx + .select({ endpoint: endpoints, owner: users }) + .from(endpoints) + .innerJoin(users, eq(endpoints.userId, users.id)) + .where(eq(endpoints.id, input.endpointId)) + .limit(1); + + if (!row) throw new LeadEndpointError(publicSubmission ? "Form not found." : "Endpoint not found."); + if (!row.endpoint.enabled) throw new LeadEndpointError("Endpoint is disabled.", 403); + + let parsedValues: Record; + let formId: string | null = null; + let formRevision: number | null = null; + let completion: AcceptanceResult["completion"]; + + if (publicSubmission) { + const publicRow = row as typeof row & { form: typeof forms.$inferSelect }; + if (!publicRow.form.publishedDefinition) throw new LeadEndpointError("Form is not published.", 404); + const definition = formDefinitionV1Schema.parse(publicRow.form.publishedDefinition); + const validation = validateFormValues(definition, input.values); + if (!validation.success) throw new LeadValidationError(validation.errors); + parsedValues = validation.data; + formId = publicRow.form.id; + formRevision = publicRow.form.publishedRevision; + completion = definition.completion; + } else { + const validation = validateEndpointValues(row.endpoint.schema, input.values); + if (!validation.success) throw new LeadValidationError(validation.errors); + parsedValues = validation.data; + } + + const plan = row.owner.plan as RouterPlan; + const entitlement = getEntitlement(plan); + const periodStart = utcPeriodStart(now); + const [usage] = await tx + .insert(usagePeriods) + .values({ userId: row.owner.id, periodStart, leadCount: 1, updatedAt: now }) + .onConflictDoUpdate({ + target: [usagePeriods.userId, usagePeriods.periodStart], + set: { + leadCount: sql`${usagePeriods.leadCount} + 1`, + updatedAt: now, + }, + }) + .returning({ leadCount: usagePeriods.leadCount }); + + const graceLimit = + entitlement.monthlyLeads === null + ? null + : Math.round(entitlement.monthlyLeads * 1.1); + if (graceLimit !== null && usage.leadCount > graceLimit) { + throw new LeadCapacityError( + getCapacityState(plan, usage.leadCount - 1) + ); + } + + const usageNotifications: UsageThreshold[] = []; + for (const threshold of crossedUsageThresholds({ + used: usage.leadCount, + limit: entitlement.monthlyLeads, + })) { + const notificationColumn = + threshold === 80 ? usagePeriods.notifiedAt80 : usagePeriods.notifiedAt100; + const [claimed] = await tx + .update(usagePeriods) + .set(threshold === 80 ? { notifiedAt80: now } : { notifiedAt100: now }) + .where( + and( + eq(usagePeriods.userId, row.owner.id), + eq(usagePeriods.periodStart, periodStart), + isNull(notificationColumn), + gte( + usagePeriods.leadCount, + threshold === 80 + ? Math.ceil(entitlement.monthlyLeads! * 0.8) + : entitlement.monthlyLeads! + ) + ) + ) + .returning({ userId: usagePeriods.userId }); + if (claimed) usageNotifications.push(threshold); + } + + const [lead] = await tx + .insert(leads) + .values({ + endpointId: row.endpoint.id, + formId, + formRevision, + placement: input.placement, + data: parsedValues, + createdAt: now, + updatedAt: now, + }) + .returning({ id: leads.id }); + + await tx + .update(users) + .set({ leadCount: sql`${users.leadCount} + 1` }) + .where(eq(users.id, row.owner.id)); + + await tx.insert(logs).values({ + endpointId: row.endpoint.id, + type: "success", + postType: publicSubmission ? "form" : "http", + message: { success: true, id: lead.id }, + createdAt: now, + }); + + const [firstPlacement] = formId + ? await tx + .insert(formPlacementMilestones) + .values({ + formId, + placement: input.placement, + firstLeadId: lead.id, + createdAt: now, + }) + .onConflictDoNothing() + .returning({ formId: formPlacementMilestones.formId }) + : []; + + return { + leadId: lead.id, + completion, + capacity: getCapacityState(plan, usage.leadCount), + ownerId: row.owner.id, + ownerEmail: row.owner.email, + formId, + firstPlacement: Boolean(firstPlacement), + periodStart, + leadCount: usage.leadCount, + monthlyLeadLimit: entitlement.monthlyLeads, + usageNotifications, + webhook: + row.endpoint.webhookEnabled && row.endpoint.webhook + ? { endpointId: row.endpoint.id, url: row.endpoint.webhook, values: parsedValues } + : null, + }; + }); + + if (accepted.webhook) await deliverWebhook(accepted.webhook); + if (accepted.formId && accepted.firstPlacement) { + await captureServerEvent({ + event: "form_first_lead_by_placement", + distinctId: accepted.ownerId, + properties: { + form_id: accepted.formId, + placement: input.placement, + }, + }); + } + if (accepted.monthlyLeadLimit !== null) { + for (const threshold of accepted.usageNotifications) { + try { + await sendUsageThresholdNotification({ + email: accepted.ownerEmail, + threshold, + used: accepted.leadCount, + limit: accepted.monthlyLeadLimit, + periodStart: accepted.periodStart, + }); + } catch (error) { + console.error(`Could not send ${threshold}% usage notification:`, error); + } + } + } + revalidatePath("/"); + revalidatePath("/leads"); + revalidatePath("/logs"); + + return { + leadId: accepted.leadId, + completion: accepted.completion, + capacity: accepted.capacity, + }; + } catch (error) { + await logRejectedLead(input, error, now); + throw error; + } +} diff --git a/lib/forms/origins.ts b/lib/forms/origins.ts new file mode 100644 index 0000000..6a77148 --- /dev/null +++ b/lib/forms/origins.ts @@ -0,0 +1,31 @@ +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +export function normalizeOrigin(input: string): string { + let url: URL; + try { + url = new URL(input); + } catch { + throw new Error("Enter a valid absolute site URL."); + } + + if (url.username || url.password || url.hostname.includes("*")) { + throw new Error("Origins cannot contain credentials or wildcards."); + } + + const isLocal = LOCAL_HOSTS.has(url.hostname); + if (url.protocol !== "https:" && !(isLocal && url.protocol === "http:")) { + throw new Error("Public form origins must use HTTPS."); + } + + return url.origin.toLowerCase(); +} + +export function requestOrigin(request: Request): string | null { + const origin = request.headers.get("origin"); + if (!origin) return null; + try { + return normalizeOrigin(origin); + } catch { + return null; + } +} diff --git a/lib/forms/public-access.ts b/lib/forms/public-access.ts new file mode 100644 index 0000000..b8b2684 --- /dev/null +++ b/lib/forms/public-access.ts @@ -0,0 +1,37 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { formOrigins, forms } from "@/lib/db/schema"; +import { normalizeOrigin } from "./origins"; +import type { FormPlacement } from "./submission-token"; + +export async function isApprovedFormOrigin(input: { + publicId: string; + origin: string; + placement: Extract; +}): Promise { + const normalized = normalizeOrigin(input.origin); + const [approval] = await db + .select({ id: formOrigins.id }) + .from(formOrigins) + .innerJoin(forms, eq(formOrigins.formId, forms.id)) + .where( + and( + eq(forms.publicId, input.publicId), + eq(formOrigins.origin, normalized), + eq(formOrigins.kind, input.placement) + ) + ) + .limit(1); + return Boolean(approval); +} + +export function publicCorsHeaders(origin: string | null, approved: boolean) { + const headers = new Headers({ Vary: "Origin" }); + if (origin && approved) { + headers.set("Access-Control-Allow-Origin", origin); + headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + headers.set("Access-Control-Allow-Headers", "Content-Type"); + headers.set("Access-Control-Max-Age", "600"); + } + return headers; +} diff --git a/lib/forms/rate-limit.ts b/lib/forms/rate-limit.ts new file mode 100644 index 0000000..9346e24 --- /dev/null +++ b/lib/forms/rate-limit.ts @@ -0,0 +1,98 @@ +import { createHmac } from "node:crypto"; +import { and, eq, lt, sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { formRateBuckets } from "@/lib/db/schema"; + +const IP_ATTEMPTS_PER_MINUTE = 60; +const FORM_ATTEMPTS_PER_MINUTE = 600; + +function rateLimitSecret(): string { + const secret = + process.env.FORM_RATE_LIMIT_SECRET ?? + process.env.FORM_SUBMISSION_SECRET ?? + process.env.AUTH_SECRET; + if (!secret) throw new Error("FORM_RATE_LIMIT_SECRET or AUTH_SECRET must be configured."); + return secret; +} + +export function hashFormIp(ip: string, now = new Date(), secret?: string): string { + const day = now.toISOString().slice(0, 10); + const dailySalt = createHmac("sha256", secret ?? rateLimitSecret()) + .update(day) + .digest(); + return createHmac("sha256", dailySalt).update(ip).digest("base64url"); +} + +function minuteWindow(now: Date): Date { + const window = new Date(now); + window.setUTCSeconds(0, 0); + return window; +} + +export class FormRateLimitError extends Error { + readonly retryAfter = 60; + + constructor(readonly scope: "ip" | "form") { + super("Too many form submission attempts. Try again in a minute."); + this.name = "FormRateLimitError"; + } +} + +export async function enforceFormRateLimit(input: { + formId: string; + ip: string; + now?: Date; +}): Promise { + const now = input.now ?? new Date(); + const windowStart = minuteWindow(now); + const keys = [ + { key: `ip:${hashFormIp(input.ip, now)}`, limit: IP_ATTEMPTS_PER_MINUTE, scope: "ip" as const }, + { key: "form", limit: FORM_ATTEMPTS_PER_MINUTE, scope: "form" as const }, + ]; + + const counts = await db.transaction(async (tx) => { + const results: Array<{ attempts: number; limit: number; scope: "ip" | "form" }> = []; + for (const bucket of keys) { + const [row] = await tx + .insert(formRateBuckets) + .values({ + formId: input.formId, + bucketKey: bucket.key, + windowStart, + attempts: 1, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + formRateBuckets.formId, + formRateBuckets.bucketKey, + formRateBuckets.windowStart, + ], + set: { + attempts: sql`${formRateBuckets.attempts} + 1`, + updatedAt: now, + }, + }) + .returning({ attempts: formRateBuckets.attempts }); + results.push({ attempts: row.attempts, limit: bucket.limit, scope: bucket.scope }); + } + return results; + }); + + const exceeded = counts.find((bucket) => bucket.attempts > bucket.limit); + if (exceeded) throw new FormRateLimitError(exceeded.scope); +} + +export async function pruneFormRateBuckets(now = new Date()): Promise { + const cutoff = new Date(now.getTime() - 24 * 60 * 60 * 1_000); + const deleted = await db + .delete(formRateBuckets) + .where(lt(formRateBuckets.updatedAt, cutoff)) + .returning({ formId: formRateBuckets.formId }); + return deleted.length; +} + +export const RATE_LIMITS = { + perIpPerForm: IP_ATTEMPTS_PER_MINUTE, + perForm: FORM_ATTEMPTS_PER_MINUTE, +} as const; diff --git a/lib/forms/starters.ts b/lib/forms/starters.ts new file mode 100644 index 0000000..683b386 --- /dev/null +++ b/lib/forms/starters.ts @@ -0,0 +1,141 @@ +import type { FormDefinitionV1 } from "./definition"; + +export type StarterId = "blank" | "contact" | "lead-capture" | "feedback" | "newsletter"; + +const completion = { + type: "message" as const, + message: "Thanks — your response has been received.", +}; + +export const FORM_STARTERS: Record = { + blank: { + version: 1, + title: "Untitled form", + description: "Add fields to start collecting responses.", + fields: [], + submitLabel: "Submit", + completion, + }, + contact: { + version: 1, + title: "Contact us", + description: "Tell us how we can help.", + fields: [ + { id: "contact_name", key: "name", kind: "text", label: "Name", required: true }, + { id: "contact_email", key: "email", kind: "email", label: "Email", required: true }, + { + id: "contact_message", + key: "message", + kind: "textarea", + label: "Message", + required: true, + rows: 5, + validation: { maxLength: 5_000 }, + }, + ], + submitLabel: "Send message", + completion, + }, + "lead-capture": { + version: 1, + title: "Get in touch", + description: "Share a few details and our team will follow up.", + fields: [ + { id: "lead_name", key: "name", kind: "text", label: "Name", required: true }, + { id: "lead_email", key: "email", kind: "email", label: "Work email", required: true }, + { id: "lead_phone", key: "phone", kind: "phone", label: "Phone", required: false }, + { id: "lead_company", key: "company", kind: "text", label: "Company", required: false }, + ], + submitLabel: "Request a conversation", + completion, + }, + feedback: { + version: 1, + title: "Share feedback", + description: "Help us understand what is working and what could be better.", + fields: [ + { + id: "feedback_score", + key: "score", + kind: "slider", + label: "How would you rate your experience?", + required: true, + defaultValue: 5, + validation: { min: 1, max: 10, step: 1 }, + }, + { + id: "feedback_notes", + key: "feedback", + kind: "textarea", + label: "What should we know?", + required: false, + rows: 5, + validation: { maxLength: 5_000 }, + }, + ], + submitLabel: "Send feedback", + completion, + }, + newsletter: { + version: 1, + title: "Stay in the loop", + description: "Occasional product news. Unsubscribe any time.", + fields: [ + { id: "newsletter_email", key: "email", kind: "email", label: "Email", required: true }, + { + id: "newsletter_consent", + key: "consent", + kind: "checkbox", + label: "I agree to receive email updates.", + required: true, + }, + ], + submitLabel: "Subscribe", + completion, + }, +}; + +export function getStarter(id: StarterId): FormDefinitionV1 { + return structuredClone(FORM_STARTERS[id]); +} + +export function seedDefinitionFromEndpoint( + name: string, + schema: Array<{ key: string; value: string; required?: boolean }> +): FormDefinitionV1 { + const usedKeys = new Set(); + return { + version: 1, + title: name, + fields: schema.map((field, index) => { + const cleaned = field.key.replace(/[^A-Za-z0-9_]/g, "_"); + const baseKey = /^[A-Za-z]/.test(cleaned) + ? cleaned + : `field_${cleaned || index + 1}`; + let key = baseKey; + let suffix = 2; + while (usedKeys.has(key)) key = `${baseKey}_${suffix++}`; + usedKeys.add(key); + return { + id: `imported_${index}_${baseKey}`, + key, + kind: + field.value === "email" || + field.value === "phone" || + field.value === "url" || + field.value === "date" || + field.value === "number" + ? field.value + : field.value === "boolean" + ? "yes-no" + : "text", + label: field.key + .replace(/[_-]+/g, " ") + .replace(/^./, (character) => character.toUpperCase()), + required: field.required ?? true, + }; + }), + submitLabel: "Submit", + completion, + } as FormDefinitionV1; +} diff --git a/lib/forms/stripe-subscription-state.ts b/lib/forms/stripe-subscription-state.ts new file mode 100644 index 0000000..ed85c5d --- /dev/null +++ b/lib/forms/stripe-subscription-state.ts @@ -0,0 +1,59 @@ +import { + LEGACY_STRIPE_PRICE_TO_PLAN, + planForNewPrice, +} from "../constants/stripe"; +import type { RouterPlan } from "./entitlements"; + +export type StripeSubscriptionSnapshot = { + priceId: string; + customerId: string; + subscriptionId: string; + status: string; + currentPeriodEnd: number; + cancelAtPeriodEnd: boolean; +}; + +export function subscriptionEntitlementState( + subscription: StripeSubscriptionSnapshot +): { + plan: RouterPlan; + stripeCustomerId: string; + stripeSubscriptionId: string; + stripeSubscriptionStatus: string; + stripeCurrentPeriodEnd: Date; + stripeCancelAtPeriodEnd: boolean; + legacyPriceMigrationRequired: boolean; +} { + const newPlan = planForNewPrice(subscription.priceId); + const legacyPlan = + LEGACY_STRIPE_PRICE_TO_PLAN[ + subscription.priceId as keyof typeof LEGACY_STRIPE_PRICE_TO_PLAN + ]; + const plan = newPlan ?? legacyPlan; + if (!plan) throw new Error(`Unrecognized Stripe price: ${subscription.priceId}`); + + return { + plan, + stripeCustomerId: subscription.customerId, + stripeSubscriptionId: subscription.subscriptionId, + stripeSubscriptionStatus: subscription.status, + stripeCurrentPeriodEnd: new Date(subscription.currentPeriodEnd * 1_000), + stripeCancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + legacyPriceMigrationRequired: Boolean(legacyPlan), + }; +} + +export function endedSubscriptionState(status: string) { + return { + plan: "free" as const, + stripeSubscriptionId: null, + stripeSubscriptionStatus: status, + stripeCurrentPeriodEnd: null, + stripeCancelAtPeriodEnd: false, + legacyPriceMigrationRequired: false, + }; +} + +export function failedPaymentState() { + return { stripeSubscriptionStatus: "past_due" } as const; +} diff --git a/lib/forms/submission-token.ts b/lib/forms/submission-token.ts new file mode 100644 index 0000000..5f199af --- /dev/null +++ b/lib/forms/submission-token.ts @@ -0,0 +1,99 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +export type FormPlacement = "hosted" | "embed" | "wordpress"; + +type SubmissionTokenInput = { + publicId: string; + placement: FormPlacement; + origin?: string; +}; + +export type SubmissionTokenPayload = SubmissionTokenInput & { + audience: "router-form-submission"; + issuedAt: string; + expiresAt: string; + nonce: string; +}; + +type TokenOptions = { + secret?: string; + now?: Date; +}; + +function signingSecret(explicit?: string): string { + const secret = + explicit ?? process.env.FORM_SUBMISSION_SECRET ?? process.env.AUTH_SECRET; + if (!secret) { + throw new Error("FORM_SUBMISSION_SECRET or AUTH_SECRET must be configured."); + } + return secret; +} + +function encode(value: string): string { + return Buffer.from(value, "utf8").toString("base64url"); +} + +function sign(encodedPayload: string, secret: string): string { + return createHmac("sha256", secret).update(encodedPayload).digest("base64url"); +} + +export function createSubmissionToken( + input: SubmissionTokenInput, + options: TokenOptions = {} +): string { + const now = options.now ?? new Date(); + const payload: SubmissionTokenPayload = { + audience: "router-form-submission", + publicId: input.publicId, + placement: input.placement, + ...(input.origin ? { origin: input.origin } : {}), + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 60 * 60 * 1_000).toISOString(), + nonce: randomBytes(12).toString("base64url"), + }; + const encodedPayload = encode(JSON.stringify(payload)); + return `${encodedPayload}.${sign(encodedPayload, signingSecret(options.secret))}`; +} + +export function verifySubmissionToken( + token: string, + options: TokenOptions = {} +): SubmissionTokenPayload { + const [encodedPayload, signature, extra] = token.split("."); + if (!encodedPayload || !signature || extra) { + throw new Error("Invalid submission token."); + } + + const expected = Buffer.from( + sign(encodedPayload, signingSecret(options.secret)), + "utf8" + ); + const actual = Buffer.from(signature, "utf8"); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { + throw new Error("Invalid submission token."); + } + + let payload: SubmissionTokenPayload; + try { + payload = JSON.parse( + Buffer.from(encodedPayload, "base64url").toString("utf8") + ) as SubmissionTokenPayload; + } catch { + throw new Error("Invalid submission token."); + } + + if ( + payload.audience !== "router-form-submission" || + !payload.publicId || + !["hosted", "embed", "wordpress"].includes(payload.placement) + ) { + throw new Error("Invalid submission token."); + } + + const now = options.now ?? new Date(); + if (!Number.isFinite(Date.parse(payload.expiresAt)) || now >= new Date(payload.expiresAt)) { + throw new Error("Submission token has expired."); + } + + return payload; +} diff --git a/lib/forms/usage-notifications.ts b/lib/forms/usage-notifications.ts new file mode 100644 index 0000000..076785d --- /dev/null +++ b/lib/forms/usage-notifications.ts @@ -0,0 +1,41 @@ +import { getResend } from "../utils/resend"; + +export type UsageThreshold = 80 | 100; + +export function crossedUsageThresholds(input: { + used: number; + limit: number | null; +}): UsageThreshold[] { + if (input.limit === null) return []; + const thresholds: UsageThreshold[] = []; + if (input.used >= Math.ceil(input.limit * 0.8)) thresholds.push(80); + if (input.used >= input.limit) thresholds.push(100); + return thresholds; +} + +export async function sendUsageThresholdNotification(input: { + email: string; + threshold: UsageThreshold; + used: number; + limit: number; + periodStart: string; +}): Promise { + if (!process.env.RESEND_API_KEY) return; + + const appUrl = process.env.ROUTER_APP_URL || "https://app.router.so"; + const subject = + input.threshold === 100 + ? "Router monthly lead allowance reached" + : "Router monthly lead allowance is 80% used"; + const graceMessage = + input.threshold === 100 + ? "Router will continue accepting leads through 110% of your allowance before pausing new submissions." + : "No action is required yet. You can review usage or choose a larger plan at any time."; + + await getResend().emails.send({ + from: process.env.ROUTER_EMAIL_FROM || "info@router.so", + to: [input.email], + subject, + text: `${subject}\n\n${input.used.toLocaleString()} of ${input.limit.toLocaleString()} leads have been accepted for the UTC month beginning ${input.periodStart}. ${graceMessage}\n\nReview usage: ${appUrl}/upgrade\n`, + }); +} diff --git a/lib/forms/wordpress-token.ts b/lib/forms/wordpress-token.ts new file mode 100644 index 0000000..4663109 --- /dev/null +++ b/lib/forms/wordpress-token.ts @@ -0,0 +1,21 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +export function createWordPressToken(): string { + const prefix = randomBytes(4).toString("hex"); + const secret = randomBytes(32).toString("base64url"); + return `rtr_wp_${prefix}_${secret}`; +} + +export function tokenPrefix(token: string): string { + return /^rtr_wp_([a-f0-9]{8})_/.exec(token)?.[1] ?? ""; +} + +export function hashWordPressToken(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} + +export function verifyWordPressToken(token: string, expectedHash: string): boolean { + const actual = Buffer.from(hashWordPressToken(token)); + const expected = Buffer.from(expectedHash); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} diff --git a/lib/types.d.ts b/lib/types.d.ts index 42f3bdf..b712a93 100644 --- a/lib/types.d.ts +++ b/lib/types.d.ts @@ -24,6 +24,16 @@ type GeneralSchema = { key: string; value: ValidationType; required?: boolean; + constraints?: { + minLength?: number; + maxLength?: number; + min?: number | string; + max?: number | string; + step?: number; + allowedValues?: string[]; + minItems?: number; + maxItems?: number; + }; }; /** @@ -40,7 +50,8 @@ type ValidationType = | "date" | "boolean" | "url" - | "zip_code"; + | "zip_code" + | "string_array"; /** * Row type for the main dashboard data on /dashboard route @@ -89,6 +100,9 @@ type LeadRow = { updatedAt: Date; endpointId: string; endpoint?: string; + formId: string | null; + formRevision: number | null; + placement: "headless" | "legacy_html" | "hosted" | "embed" | "wordpress" | null; }; /** diff --git a/lib/utils/resend.ts b/lib/utils/resend.ts index 6ed938e..f7113ef 100644 --- a/lib/utils/resend.ts +++ b/lib/utils/resend.ts @@ -1,3 +1,10 @@ import { Resend } from "resend"; -export const resend = new Resend(process.env.RESEND_API_KEY); +let client: Resend | null = null; + +export function getResend(): Resend { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) throw new Error("RESEND_API_KEY is not configured."); + client ??= new Resend(apiKey); + return client; +} diff --git a/lib/utils/stripe-client.ts b/lib/utils/stripe-client.ts new file mode 100644 index 0000000..d325a4f --- /dev/null +++ b/lib/utils/stripe-client.ts @@ -0,0 +1,12 @@ +import Stripe from "stripe"; + +let stripe: Stripe | null = null; + +export function getStripe(): Stripe { + const apiKey = process.env.STRIPE_SECRET_KEY; + if (!apiKey) { + throw new Error("Stripe is not configured for this environment."); + } + stripe ??= new Stripe(apiKey); + return stripe; +} diff --git a/lib/validation/index.ts b/lib/validation/index.ts index 73ea303..82cfa75 100644 --- a/lib/validation/index.ts +++ b/lib/validation/index.ts @@ -20,6 +20,7 @@ export const validationOptions: { name: ValidationType }[] = [ { name: "boolean" }, { name: "url" }, { name: "zip_code" }, + { name: "string_array" }, ]; /** @@ -37,6 +38,7 @@ export const normalizedValidationOption = { boolean: "Boolean", url: "URL", zip_code: "Zip Code", + string_array: "String Array", } /** @@ -58,6 +60,7 @@ export const validations: { [key in ValidationType]: z.ZodType } = { .string() .min(5, "Not a valid zip code.") .max(5, "Not a valid zip code."), + string_array: z.array(z.string()), }; /** @@ -81,6 +84,8 @@ export const convertToCorrectTypes = ( // Convert string to number, ensuring NaN is handled appropriately const num = Number(data[key]); result[key] = isNaN(num) ? undefined : num; + } else if (value === "string_array") { + result[key] = Array.isArray(data[key]) ? data[key] : [data[key]].filter(Boolean); } else { // For all other types, assume string or no conversion needed result[key] = data[key]; diff --git a/middleware.ts b/middleware.ts index ee4030a..5e937d9 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,4 +1,29 @@ -export { auth as middleware } from "@/lib/auth"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; + +export default auth((request) => { + if (request.nextUrl.hostname !== "forms.router.so") { + return NextResponse.next(); + } + + const pathname = request.nextUrl.pathname; + if ( + pathname.startsWith("/_next/") || + pathname.startsWith("/api/") || + pathname.startsWith("/embed/") || + pathname === "/favicon.ico" + ) { + return NextResponse.next(); + } + + const publicId = pathname.split("/").filter(Boolean)[0]; + if (!publicId) { + return NextResponse.rewrite(new URL("/f/not-found", request.url)); + } + return NextResponse.rewrite( + new URL(`/f/${encodeURIComponent(publicId)}`, request.url) + ); +}); export const config = { matcher: [ diff --git a/package.json b/package.json index 8234867..5f40a2c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,13 @@ "build": "next build", "start": "next start", "lint": "eslint .", - "test:unit": "vitest", + "typecheck": "tsc --noEmit", + "test:unit": "vitest --run", + "test:unit:watch": "vitest", + "test:db": "vitest --run __tests__/forms-db.integration.test.ts", + "wordpress:check": "integrations/wordpress/check.sh", + "wordpress:package": "integrations/wordpress/package.sh", + "stripe:legacy-migration": "tsx scripts/stripe-legacy-migration.ts", "db:generate": "drizzle-kit generate", "db:migrate": "tsx lib/db/migrate.ts" }, @@ -92,6 +98,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", "@types/node": "^24.10.6", + "@types/pg": "^8.15.5", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/validator": "^13.15.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4783dc3..604c270 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,7 +160,7 @@ importers: version: 3.6.0 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@types/pg@8.11.6)(@vercel/postgres@0.10.0(utf-8-validate@6.0.5))(pg@8.23.0) + version: 0.45.2(@types/pg@8.23.1)(@vercel/postgres@0.10.0(utf-8-validate@6.0.5))(pg@8.23.0) embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.8) @@ -246,6 +246,9 @@ importers: '@types/node': specifier: ^24.10.6 version: 24.13.3 + '@types/pg': + specifier: ^8.15.5 + version: 8.23.1 '@types/react': specifier: ^19.2.18 version: 19.2.18 @@ -2225,6 +2228,9 @@ packages: '@types/pg@8.11.6': resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==} + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/react-dom@19.2.4': resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: @@ -3848,9 +3854,6 @@ packages: peerDependencies: pg: '>=8.0' - pg-protocol@1.10.0: - resolution: {integrity: sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==} - pg-protocol@1.16.0: resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} @@ -6429,9 +6432,15 @@ snapshots: '@types/pg@8.11.6': dependencies: '@types/node': 24.13.3 - pg-protocol: 1.10.0 + pg-protocol: 1.16.0 pg-types: 4.0.2 + '@types/pg@8.23.1': + dependencies: + '@types/node': 24.13.3 + pg-protocol: 1.16.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -7061,9 +7070,9 @@ snapshots: esbuild: 0.25.5 tsx: 4.23.12 - drizzle-orm@0.45.2(@types/pg@8.11.6)(@vercel/postgres@0.10.0(utf-8-validate@6.0.5))(pg@8.23.0): + drizzle-orm@0.45.2(@types/pg@8.23.1)(@vercel/postgres@0.10.0(utf-8-validate@6.0.5))(pg@8.23.0): optionalDependencies: - '@types/pg': 8.11.6 + '@types/pg': 8.23.1 '@vercel/postgres': 0.10.0(utf-8-validate@6.0.5) pg: 8.23.0 @@ -8181,8 +8190,6 @@ snapshots: dependencies: pg: 8.23.0 - pg-protocol@1.10.0: {} - pg-protocol@1.16.0: {} pg-types@2.2.0: diff --git a/public/downloads/router-forms.zip b/public/downloads/router-forms.zip new file mode 100644 index 0000000000000000000000000000000000000000..782e6a0f1b5bdbf532c41ac5ff7005eb6b87ac6a GIT binary patch literal 4833 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 literal 0 HcmV?d00001 diff --git a/public/embed/v1.js b/public/embed/v1.js new file mode 100644 index 0000000..dd85387 --- /dev/null +++ b/public/embed/v1.js @@ -0,0 +1,356 @@ +(function () { + "use strict"; + + if (window.RouterFormsV1) { + window.RouterFormsV1.scan(); + return; + } + + var initialized = new WeakSet(); + var script = document.currentScript; + var apiBase = script && script.src ? new URL(script.src, window.location.href).origin : "https://forms.router.so"; + var styleId = "router-forms-v1-styles"; + + function installStyles() { + if (document.getElementById(styleId)) return; + var style = document.createElement("style"); + style.id = styleId; + style.textContent = [ + ".router-form-v1{--rf-color:var(--router-form-color,currentColor);--rf-muted:var(--router-form-muted-color,color-mix(in srgb,currentColor 62%,transparent));--rf-border:var(--router-form-border-color,color-mix(in srgb,currentColor 22%,transparent));--rf-surface:var(--router-form-surface,transparent);--rf-accent:var(--router-form-accent,currentColor);--rf-accent-contrast:var(--router-form-accent-contrast,Canvas);color:var(--rf-color);font:inherit;line-height:1.5;width:100%}", + ".router-form-v1,.router-form-v1 *{box-sizing:border-box}", + ".router-form-v1__header{margin:0 0 1.5rem}", + ".router-form-v1__title{color:inherit;font:inherit;font-size:clamp(1.5rem,4vw,2.25rem);font-weight:650;letter-spacing:-.025em;line-height:1.15;margin:0}", + ".router-form-v1__description{color:var(--rf-muted);font:inherit;margin:.6rem 0 0;max-width:60ch}", + ".router-form-v1__fields{display:grid;gap:1.1rem}", + ".router-form-v1__field{border:0;display:grid;gap:.42rem;margin:0;min-width:0;padding:0}", + ".router-form-v1__label,.router-form-v1__legend{color:inherit;font:inherit;font-size:.925rem;font-weight:600;margin:0;padding:0}", + ".router-form-v1__required{color:var(--rf-muted);font-weight:400;margin-left:.2rem}", + ".router-form-v1__help{color:var(--rf-muted);font-size:.825rem;margin:0}", + ".router-form-v1__input,.router-form-v1__select,.router-form-v1__textarea{appearance:none;background:var(--rf-surface);border:1px solid var(--rf-border);border-radius:var(--router-form-radius,.6rem);color:inherit;font:inherit;font-size:1rem;line-height:1.4;min-height:2.75rem;padding:.68rem .78rem;width:100%}", + ".router-form-v1__textarea{min-height:7rem;resize:vertical}", + ".router-form-v1__input:focus,.router-form-v1__select:focus,.router-form-v1__textarea:focus{border-color:var(--rf-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--rf-accent) 18%,transparent);outline:none}", + ".router-form-v1__choices{display:grid;gap:.55rem}", + ".router-form-v1__choice{align-items:flex-start;cursor:pointer;display:flex;font:inherit;gap:.55rem}", + ".router-form-v1__choice input{accent-color:var(--rf-accent);height:1.05rem;margin:.22rem 0 0;width:1.05rem}", + ".router-form-v1__range{accent-color:var(--rf-accent);width:100%}", + ".router-form-v1__range-value{color:var(--rf-muted);font-size:.825rem}", + ".router-form-v1__error{color:var(--router-form-error,#b42318);font-size:.825rem;margin:0}", + ".router-form-v1__invalid{border-color:var(--router-form-error,#b42318)!important}", + ".router-form-v1__submit{appearance:none;background:var(--rf-accent);border:1px solid var(--rf-accent);border-radius:var(--router-form-radius,.6rem);color:var(--rf-accent-contrast);cursor:pointer;font:inherit;font-weight:650;margin-top:1.35rem;min-height:2.8rem;padding:.7rem 1.05rem}", + ".router-form-v1__submit:hover{filter:brightness(.94)}", + ".router-form-v1__submit:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,var(--rf-accent) 24%,transparent);outline:none}", + ".router-form-v1__submit[disabled]{cursor:wait;opacity:.62}", + ".router-form-v1__status{border:1px solid var(--rf-border);border-radius:var(--router-form-radius,.6rem);margin:0;padding:1rem}", + ".router-form-v1__attribution{color:var(--rf-muted);font-size:.72rem;margin:1rem 0 0}", + ".router-form-v1__attribution a{color:inherit}", + ".router-form-v1__honeypot{height:1px!important;left:-10000px!important;overflow:hidden!important;position:absolute!important;width:1px!important}", + "@media(prefers-reduced-motion:no-preference){.router-form-v1__submit{transition:filter .15s ease,opacity .15s ease}}" + ].join(""); + document.head.appendChild(style); + } + + function element(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function controlId(publicId, fieldId) { + return "router-form-" + publicId + "-" + fieldId; + } + + function appendHelp(container, field, id) { + if (!field.helpText) return null; + var help = element("p", "router-form-v1__help", field.helpText); + help.id = id + "-help"; + container.appendChild(help); + return help.id; + } + + function setCommon(control, field, id, helpId) { + control.id = id; + control.name = field.key; + if (field.required) control.required = true; + if (field.placeholder) control.placeholder = field.placeholder; + if (helpId) control.setAttribute("aria-describedby", helpId); + control.classList.add("router-form-v1__input"); + } + + function renderField(field, publicId) { + var isGroup = ["radio", "checkbox-group", "yes-no"].indexOf(field.kind) !== -1; + var wrapper = element(isGroup ? "fieldset" : "div", "router-form-v1__field"); + wrapper.dataset.routerField = field.key; + var id = controlId(publicId, field.id); + var label = element(isGroup ? "legend" : "label", isGroup ? "router-form-v1__legend" : "router-form-v1__label", field.label); + if (!isGroup) label.htmlFor = id; + if (field.required) label.appendChild(element("span", "router-form-v1__required", " (required)")); + wrapper.appendChild(label); + var helpId = appendHelp(wrapper, field, id); + + if (field.kind === "textarea") { + var textarea = document.createElement("textarea"); + setCommon(textarea, field, id, helpId); + textarea.className = "router-form-v1__textarea"; + textarea.rows = field.rows || 4; + if (field.defaultValue) textarea.value = field.defaultValue; + if (field.validation && field.validation.minLength !== undefined) textarea.minLength = field.validation.minLength; + if (field.validation && field.validation.maxLength !== undefined) textarea.maxLength = field.validation.maxLength; + wrapper.appendChild(textarea); + } else if (field.kind === "select") { + var select = document.createElement("select"); + setCommon(select, field, id, helpId); + select.className = "router-form-v1__select"; + var placeholder = element("option", "", field.placeholder || "Choose an option"); + placeholder.value = ""; + placeholder.disabled = field.required; + placeholder.selected = !field.defaultValue; + select.appendChild(placeholder); + field.options.forEach(function (option) { + var optionNode = element("option", "", option.label); + optionNode.value = option.value; + optionNode.selected = field.defaultValue === option.value; + select.appendChild(optionNode); + }); + wrapper.appendChild(select); + } else if (field.kind === "radio" || field.kind === "checkbox-group" || field.kind === "yes-no") { + var choices = element("div", "router-form-v1__choices"); + choices.setAttribute("role", field.kind === "radio" || field.kind === "yes-no" ? "radiogroup" : "group"); + if (helpId) choices.setAttribute("aria-describedby", helpId); + var options = field.kind === "yes-no" + ? [{ id: "yes", label: "Yes", value: "true" }, { id: "no", label: "No", value: "false" }] + : field.options; + options.forEach(function (option, index) { + var choiceLabel = element("label", "router-form-v1__choice"); + var input = document.createElement("input"); + input.type = field.kind === "checkbox-group" ? "checkbox" : "radio"; + input.name = field.key; + input.value = option.value; + input.id = id + "-" + index; + if (field.required && index === 0) input.required = true; + var defaults = Array.isArray(field.defaultValue) ? field.defaultValue : [String(field.defaultValue)]; + input.checked = defaults.indexOf(option.value) !== -1; + choiceLabel.appendChild(input); + choiceLabel.appendChild(document.createTextNode(option.label)); + choices.appendChild(choiceLabel); + }); + wrapper.appendChild(choices); + } else if (field.kind === "checkbox" || field.kind === "switch") { + label.remove(); + var checkLabel = element("label", "router-form-v1__choice"); + var checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.id = id; + checkbox.name = field.key; + checkbox.required = Boolean(field.required); + checkbox.checked = Boolean(field.defaultValue); + checkLabel.appendChild(checkbox); + checkLabel.appendChild(document.createTextNode(field.label + (field.required ? " (required)" : ""))); + wrapper.insertBefore(checkLabel, wrapper.firstChild); + } else if (field.kind === "slider") { + var range = document.createElement("input"); + range.type = "range"; + range.id = id; + range.name = field.key; + range.className = "router-form-v1__range"; + range.min = String(field.validation && field.validation.min !== undefined ? field.validation.min : 0); + range.max = String(field.validation && field.validation.max !== undefined ? field.validation.max : 100); + range.step = String(field.validation && field.validation.step !== undefined ? field.validation.step : 1); + range.value = String(field.defaultValue !== undefined ? field.defaultValue : range.min); + var rangeValue = element("output", "router-form-v1__range-value", range.value); + rangeValue.htmlFor = id; + range.addEventListener("input", function () { rangeValue.textContent = range.value; }); + wrapper.appendChild(range); + wrapper.appendChild(rangeValue); + } else { + var input = document.createElement("input"); + var types = { email: "email", phone: "tel", url: "url", date: "date", number: "number" }; + input.type = types[field.kind] || "text"; + setCommon(input, field, id, helpId); + if (field.defaultValue !== undefined) input.value = String(field.defaultValue); + if (field.validation) { + if (field.validation.minLength !== undefined) input.minLength = field.validation.minLength; + if (field.validation.maxLength !== undefined) input.maxLength = field.validation.maxLength; + if (field.validation.min !== undefined) input.min = String(field.validation.min); + if (field.validation.max !== undefined) input.max = String(field.validation.max); + if (field.validation.step !== undefined) input.step = String(field.validation.step); + } + wrapper.appendChild(input); + } + return wrapper; + } + + function valuesFrom(form, definition) { + var values = {}; + definition.fields.forEach(function (field) { + var controls = form.elements[field.key]; + if (field.kind === "checkbox-group") { + var group = controls && controls.length !== undefined ? Array.prototype.slice.call(controls) : [controls]; + values[field.key] = group.filter(function (control) { return control && control.checked; }).map(function (control) { return control.value; }); + } else if (field.kind === "radio" || field.kind === "yes-no") { + var checked = form.querySelector('[name="' + CSS.escape(field.key) + '"]:checked'); + if (checked) values[field.key] = field.kind === "yes-no" ? checked.value === "true" : checked.value; + } else if (field.kind === "checkbox" || field.kind === "switch") { + values[field.key] = Boolean(controls && controls.checked); + } else if (field.kind === "number" || field.kind === "slider") { + if (controls && controls.value !== "") values[field.key] = Number(controls.value); + } else if (controls && controls.value !== "") { + values[field.key] = controls.value; + } + }); + return values; + } + + function showErrors(form, errors) { + form.querySelectorAll(".router-form-v1__error").forEach(function (node) { node.remove(); }); + form.querySelectorAll(".router-form-v1__invalid").forEach(function (node) { + node.classList.remove("router-form-v1__invalid"); + node.removeAttribute("aria-invalid"); + }); + var first = null; + Object.keys(errors || {}).forEach(function (key) { + var wrapper = form.querySelector('[data-router-field="' + CSS.escape(key) + '"]'); + if (!wrapper) return; + var control = wrapper.querySelector("input,select,textarea"); + var error = element("p", "router-form-v1__error", errors[key].join(" ")); + error.id = controlId(form.dataset.publicId, key) + "-error"; + error.setAttribute("role", "alert"); + wrapper.appendChild(error); + if (control) { + control.classList.add("router-form-v1__invalid"); + control.setAttribute("aria-invalid", "true"); + first = first || control; + } + }); + if (first) first.focus(); + } + + function render(target, payload, options) { + installStyles(); + var definition = payload.definition || payload; + var publicId = payload.publicId || options.publicId || "preview"; + target.replaceChildren(); + var root = element("section", "router-form-v1"); + var header = element("header", "router-form-v1__header"); + header.appendChild(element(options.placement === "hosted" ? "h1" : "h2", "router-form-v1__title", definition.title)); + if (definition.description) header.appendChild(element("p", "router-form-v1__description", definition.description)); + root.appendChild(header); + var form = element("form", "router-form-v1__form"); + form.dataset.publicId = publicId; + form.noValidate = false; + var fields = element("div", "router-form-v1__fields"); + definition.fields.forEach(function (field) { fields.appendChild(renderField(field, publicId)); }); + form.appendChild(fields); + var honeypot = element("div", "router-form-v1__honeypot"); + honeypot.setAttribute("aria-hidden", "true"); + var honeypotLabel = element("label", "", "Leave this field empty"); + var honeypotInput = document.createElement("input"); + honeypotInput.name = "website"; + honeypotInput.tabIndex = -1; + honeypotInput.autocomplete = "off"; + honeypotLabel.appendChild(honeypotInput); + honeypot.appendChild(honeypotLabel); + form.appendChild(honeypot); + var submit = element("button", "router-form-v1__submit", definition.submitLabel); + submit.type = "submit"; + form.appendChild(submit); + root.appendChild(form); + if (payload.attribution && payload.attribution.visible) { + var attribution = element("p", "router-form-v1__attribution"); + var link = element("a", "", payload.attribution.label); + link.href = payload.attribution.href; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + attribution.appendChild(link); + root.appendChild(attribution); + } + target.appendChild(root); + + if (options.preview) { + form.addEventListener("submit", function (event) { event.preventDefault(); }); + return; + } + + form.addEventListener("submit", async function (event) { + event.preventDefault(); + if (!form.reportValidity()) return; + submit.disabled = true; + submit.textContent = "Submitting…"; + try { + var response = await fetch(apiBase + "/api/public/forms/" + encodeURIComponent(publicId) + "/leads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values: valuesFrom(form, definition), submitToken: options.submitToken, website: honeypotInput.value }) + }); + var result = await response.json().catch(function () { return {}; }); + if (!response.ok) { + if (result.fields) showErrors(form, result.fields); + else throw new Error(result.error === "monthly_capacity_reached" ? "This form is temporarily paused." : "We couldn’t submit the form. Please try again."); + return; + } + if (result.completion && result.completion.type === "redirect") { + window.location.assign(result.completion.url); + return; + } + root.replaceChildren(element("p", "router-form-v1__status", result.completion && result.completion.message ? result.completion.message : "Thanks — your response has been received.")); + } catch (error) { + var status = element("p", "router-form-v1__status", error && error.message ? error.message : "Router is unavailable. Please try again."); + status.setAttribute("role", "alert"); + form.appendChild(status); + } finally { + submit.disabled = false; + submit.textContent = definition.submitLabel; + } + }); + } + + async function mount(target, options) { + options = options || {}; + if (!options.preview && initialized.has(target)) return; + initialized.add(target); + var publicId = options.publicId || target.getAttribute("data-router-form"); + var placement = options.placement || target.getAttribute("data-router-placement") || "embed"; + try { + if (options.definition) { + render(target, options.definition, { preview: true, publicId: publicId, placement: options.placement || "embed" }); + return; + } + target.setAttribute("aria-busy", "true"); + var responses = await Promise.all([ + fetch(apiBase + "/api/public/forms/" + encodeURIComponent(publicId)), + fetch(apiBase + "/api/public/forms/" + encodeURIComponent(publicId) + "/render-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ placement: placement }) + }) + ]); + if (!responses[0].ok || !responses[1].ok) throw new Error("This form is unavailable."); + var payload = await responses[0].json(); + var session = await responses[1].json(); + render(target, payload, { publicId: publicId, placement: placement, submitToken: session.submitToken }); + } catch (error) { + target.replaceChildren(element("p", "router-form-v1 router-form-v1__status", error && error.message ? error.message : "This form is unavailable.")); + } finally { + target.removeAttribute("aria-busy"); + } + } + + function scan(root) { + var scope = root || document; + scope.querySelectorAll("[data-router-form]").forEach(function (target) { mount(target); }); + } + + window.RouterFormsV1 = { mount: mount, render: render, scan: scan }; + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { scan(); }); + else scan(); + new MutationObserver(function (records) { + records.forEach(function (record) { + record.addedNodes.forEach(function (node) { + if (node.nodeType === 1) { + if (node.matches && node.matches("[data-router-form]")) mount(node); + scan(node); + } + }); + }); + }).observe(document.documentElement, { childList: true, subtree: true }); +})(); diff --git a/scripts/stripe-legacy-migration.ts b/scripts/stripe-legacy-migration.ts new file mode 100644 index 0000000..971f8f4 --- /dev/null +++ b/scripts/stripe-legacy-migration.ts @@ -0,0 +1,58 @@ +import { LEGACY_STRIPE_PRICE_TO_PLAN } from "../lib/constants/stripe"; +import { getStripe } from "../lib/utils/stripe-client"; + +async function main() { + const apply = process.argv.includes("--apply"); + const stripe = getStripe(); + let inspected = 0; + let alreadyScheduled = 0; + let changed = 0; + + for (const price of Object.keys(LEGACY_STRIPE_PRICE_TO_PLAN)) { + for await (const subscription of stripe.subscriptions.list({ + price, + status: "all", + limit: 100, + })) { + if (!["active", "trialing", "past_due", "unpaid"].includes(subscription.status)) { + continue; + } + inspected += 1; + if (subscription.cancel_at_period_end) { + alreadyScheduled += 1; + console.log(`${subscription.id}\talready scheduled\t${price}`); + continue; + } + + if (apply) { + await stripe.subscriptions.update(subscription.id, { + cancel_at_period_end: true, + }); + changed += 1; + console.log(`${subscription.id}\tscheduled\t${price}`); + } else { + console.log(`${subscription.id}\twould schedule\t${price}`); + } + } + } + + console.log( + JSON.stringify({ + mode: apply ? "apply" : "dry-run", + inspected, + alreadyScheduled, + changed, + }) + ); + + if (!apply) { + console.log( + "Dry run only. Re-run with --apply after reviewing the exact subscriptions and obtaining release authorization." + ); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 34827c159522c6bc9162a110ffee939ff6152708 Mon Sep 17 00:00:00 2001 From: Bridger Tower Date: Tue, 1 Sep 2026 20:03:43 -0600 Subject: [PATCH 02/17] fix: harden Router Forms lifecycle --- __tests__/cron-config.test.ts | 15 + __tests__/embed-runtime.test.ts | 54 + __tests__/forms-db.integration.test.ts | 55 +- __tests__/forms-definition.test.ts | 138 ++ __tests__/forms-security.test.ts | 19 + __tests__/usage-notifications.test.ts | 20 +- app/api/cron/forms-maintenance/route.ts | 16 + app/api/cron/route.ts | 4 +- app/api/public/forms/[publicId]/route.ts | 3 +- app/endpoints/[id]/page.tsx | 5 +- app/forms/create/page.tsx | 14 +- lib/data/endpoints.ts | 23 +- lib/data/forms.ts | 30 +- ...0011_usage_notification_delivery_lease.sql | 2 + ...0012_usage_notification_pending_limits.sql | 2 + lib/db/drizzle/meta/0011_snapshot.json | 1268 ++++++++++++++++ lib/db/drizzle/meta/0012_snapshot.json | 1280 +++++++++++++++++ lib/db/drizzle/meta/_journal.json | 16 +- lib/db/schema.ts | 4 + lib/forms/cache.ts | 8 + lib/forms/definition.ts | 134 +- lib/forms/endpoint-schema.ts | 30 +- lib/forms/field-constraints.ts | 50 + lib/forms/lead-acceptance.ts | 70 +- lib/forms/starters.ts | 199 ++- lib/forms/usage-notifications.ts | 180 ++- public/embed/v1.js | 34 +- vercel.json | 4 + 28 files changed, 3559 insertions(+), 118 deletions(-) create mode 100644 __tests__/cron-config.test.ts create mode 100644 app/api/cron/forms-maintenance/route.ts create mode 100644 lib/db/drizzle/0011_usage_notification_delivery_lease.sql create mode 100644 lib/db/drizzle/0012_usage_notification_pending_limits.sql create mode 100644 lib/db/drizzle/meta/0011_snapshot.json create mode 100644 lib/db/drizzle/meta/0012_snapshot.json create mode 100644 lib/forms/field-constraints.ts diff --git a/__tests__/cron-config.test.ts b/__tests__/cron-config.test.ts new file mode 100644 index 0000000..bc6be2c --- /dev/null +++ b/__tests__/cron-config.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("scheduled maintenance", () => { + it("runs form rate-bucket pruning at least hourly", () => { + const config = JSON.parse(readFileSync("vercel.json", "utf8")) as { + crons: Array<{ path: string; schedule: string }>; + }; + + expect(config.crons).toContainEqual({ + path: "/api/cron/forms-maintenance", + schedule: "17 * * * *", + }); + }); +}); diff --git a/__tests__/embed-runtime.test.ts b/__tests__/embed-runtime.test.ts index f598940..2656d8f 100644 --- a/__tests__/embed-runtime.test.ts +++ b/__tests__/embed-runtime.test.ts @@ -78,6 +78,60 @@ describe("embed v1 runtime", () => { expect(document.querySelectorAll("#router-forms-v1-styles")).toHaveLength(1); }); + it("allows any option to satisfy a required checkbox group", async () => { + const target = document.createElement("div"); + document.body.appendChild(target); + const runtime = (window as unknown as { + RouterFormsV1: { mount: (target: Element, options: object) => Promise }; + }).RouterFormsV1; + const groupDefinition: FormDefinitionV1 = { + ...definition, + fields: [ + { + id: "group", + key: "group", + kind: "checkbox-group", + label: "Group", + required: true, + options: [ + { id: "group_a", label: "A", value: "a" }, + { id: "group_b", label: "B", value: "b" }, + ], + }, + ], + }; + + await runtime.mount(target, { + definition: groupDefinition, + publicId: "required-group", + preview: true, + }); + + const form = target.querySelector("form")!; + const checkboxes = target.querySelectorAll('input[type="checkbox"]'); + checkboxes[1].click(); + expect(form.checkValidity()).toBe(true); + }); + + it("uses unique control IDs when the same form is mounted twice", async () => { + const first = document.createElement("div"); + const second = document.createElement("div"); + document.body.append(first, second); + const runtime = (window as unknown as { + RouterFormsV1: { mount: (target: Element, options: object) => Promise }; + }).RouterFormsV1; + + await Promise.all([ + runtime.mount(first, { definition, publicId: "duplicate", preview: true }), + runtime.mount(second, { definition, publicId: "duplicate", preview: true }), + ]); + + const ids = Array.from(document.querySelectorAll("[id]")) + .map((element) => element.id) + .filter((id) => id !== "router-forms-v1-styles"); + expect(new Set(ids).size).toBe(ids.length); + }); + it.each(Object.entries(FORM_STARTERS))( "renders the %s starter through the production runtime", async (_starterId, starter) => { diff --git a/__tests__/forms-db.integration.test.ts b/__tests__/forms-db.integration.test.ts index 1ea3537..235d0ce 100644 --- a/__tests__/forms-db.integration.test.ts +++ b/__tests__/forms-db.integration.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Pool } from "pg"; import { drizzle } from "drizzle-orm/node-postgres"; -import { eq, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { endpoints, forms, usagePeriods, users } from "../lib/db/schema"; import { FORM_STARTERS } from "../lib/forms/starters"; import { compileEndpointSchema } from "../lib/forms/definition"; @@ -71,6 +71,27 @@ suite("Forms PostgreSQL integration", () => { expect(endpoint.schema).toEqual(compileEndpointSchema(FORM_STARTERS.contact)); }); + it("allows only one publisher to claim the same draft and public revision", async () => { + const attempts = await Promise.all( + Array.from({ length: 2 }, () => + database + .update(forms) + .set({ publishedRevision: sql`${forms.publishedRevision} + 1` }) + .where( + and( + eq(forms.id, formId), + eq(forms.draftRevision, 1), + eq(forms.publishedRevision, 1) + ) + ) + .returning({ revision: forms.publishedRevision }) + ) + ); + + expect(attempts.flat()).toHaveLength(1); + expect(attempts.flat()[0].revision).toBe(2); + }); + it("blocks endpoint deletion while its form exists", async () => { await expect(database.delete(endpoints).where(eq(endpoints.id, endpointId))).rejects.toThrow(); }); @@ -94,4 +115,36 @@ suite("Forms PostgreSQL integration", () => { .where(eq(usagePeriods.userId, userId)); expect(usage.leadCount).toBe(20); }); + + it("leases a usage notification to only one concurrent sender", async () => { + const periodStart = "2026-09-01"; + const claimTime = new Date("2026-09-01T12:00:00.000Z"); + await database + .update(usagePeriods) + .set({ notificationLimit80: 100 }) + .where( + and( + eq(usagePeriods.userId, userId), + eq(usagePeriods.periodStart, periodStart) + ) + ); + const attempts = await Promise.all( + Array.from({ length: 2 }, () => + database + .update(usagePeriods) + .set({ notifyingAt80: claimTime }) + .where( + and( + eq(usagePeriods.userId, userId), + eq(usagePeriods.periodStart, periodStart), + isNull(usagePeriods.notifiedAt80), + isNull(usagePeriods.notifyingAt80) + ) + ) + .returning({ userId: usagePeriods.userId }) + ) + ); + + expect(attempts.flat()).toHaveLength(1); + }); }); diff --git a/__tests__/forms-definition.test.ts b/__tests__/forms-definition.test.ts index 7104a41..740c148 100644 --- a/__tests__/forms-definition.test.ts +++ b/__tests__/forms-definition.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it } from "vitest"; import { compileEndpointSchema, + formDraftDefinitionV1Schema, formDefinitionV1Schema, validateFormValues, } from "../lib/forms/definition"; +import { validateEndpointValues } from "../lib/forms/endpoint-schema"; +import { + isEndpointSchemaCompatible, + seedDefinitionFromEndpoint, +} from "../lib/forms/starters"; const contactForm = { version: 1 as const, @@ -69,6 +75,25 @@ describe("FormDefinitionV1", () => { expect(result.success).toBe(false); }); + it("stores structurally valid incomplete drafts without making them publishable", () => { + const incomplete = { + ...contactForm, + title: "", + submitLabel: "", + completion: { type: "redirect" as const, url: "https://" }, + fields: [ + { + ...contactForm.fields[0], + key: "", + validation: { minLength: 10, maxLength: 2 }, + }, + ], + }; + + expect(formDraftDefinitionV1Schema.safeParse(incomplete).success).toBe(true); + expect(formDefinitionV1Schema.safeParse(incomplete).success).toBe(false); + }); + it("compiles fields into Router's endpoint schema", () => { expect(compileEndpointSchema(contactForm)).toEqual([ { @@ -90,6 +115,45 @@ describe("FormDefinitionV1", () => { }, ]); }); + + it("rejects endpoint attachments that cannot be represented without contract drift", () => { + const unsupported = [{ key: "tags", value: "string_array" }]; + + expect(isEndpointSchemaCompatible(unsupported)).toBe(false); + expect( + isEndpointSchemaCompatible([{ key: "full name", value: "string" }]) + ).toBe(false); + expect(() => seedDefinitionFromEndpoint("Tags", unsupported)).toThrow( + "cannot be represented" + ); + }); + + it("preserves supported legacy constraints when seeding an attached form", () => { + const seeded = seedDefinitionFromEndpoint("Qualified lead", [ + { key: "name", value: "string" }, + { key: "postal_code", value: "zip_code", required: true }, + { + key: "interests", + value: "string_array", + required: true, + constraints: { + allowedValues: ["sales", "support"], + minItems: 1, + maxItems: 2, + }, + }, + ]); + + expect(seeded.fields).toMatchObject([ + { kind: "text", validation: { minLength: 2 } }, + { kind: "text", validation: { minLength: 5, maxLength: 5 } }, + { + kind: "checkbox-group", + options: [{ value: "sales" }, { value: "support" }], + validation: { minSelections: 1, maxSelections: 2 }, + }, + ]); + }); }); describe("validateFormValues", () => { @@ -128,4 +192,78 @@ describe("validateFormValues", () => { }); } }); + + it("enforces every authored string and number constraint", () => { + const constrainedForm = formDefinitionV1Schema.parse({ + ...contactForm, + fields: [ + { + id: "fld_email", + key: "email", + kind: "email", + label: "Email", + required: true, + validation: { minLength: 18, maxLength: 30 }, + }, + { + id: "fld_score", + key: "score", + kind: "number", + label: "Score", + required: true, + validation: { min: 1, max: 10, step: 2 }, + }, + { + id: "fld_phone", + key: "phone", + kind: "phone", + label: "Phone", + required: true, + validation: { minLength: 20 }, + }, + { + id: "fld_url", + key: "url", + kind: "url", + label: "URL", + required: true, + validation: { maxLength: 10 }, + }, + ], + }); + + expect( + validateFormValues(constrainedForm, { + email: "a@example.com", + score: 2, + phone: "+12025550123", + url: "https://example.com", + }) + ).toMatchObject({ + success: false, + errors: { + email: expect.any(Array), + score: expect.any(Array), + phone: expect.any(Array), + url: expect.any(Array), + }, + }); + + expect( + validateEndpointValues(compileEndpointSchema(constrainedForm), { + email: "a@example.com", + score: 2, + phone: "+12025550123", + url: "https://example.com", + }) + ).toMatchObject({ + success: false, + errors: { + email: expect.any(Array), + score: expect.any(Array), + phone: expect.any(Array), + url: expect.any(Array), + }, + }); + }); }); diff --git a/__tests__/forms-security.test.ts b/__tests__/forms-security.test.ts index 73a3258..1e84cd9 100644 --- a/__tests__/forms-security.test.ts +++ b/__tests__/forms-security.test.ts @@ -4,6 +4,7 @@ import { verifySubmissionToken, } from "../lib/forms/submission-token"; import { normalizeOrigin } from "../lib/forms/origins"; +import { publishedFormEtag } from "../lib/forms/cache"; describe("normalizeOrigin", () => { it("normalizes a site URL to a stable origin", () => { @@ -67,3 +68,21 @@ describe("signed form submission tokens", () => { vi.useRealTimers(); }); }); + +describe("published form cache validators", () => { + it("changes when attribution visibility changes without a form revision", () => { + expect( + publishedFormEtag({ + publicId: "form_public_1", + revision: 4, + showAttribution: true, + }) + ).not.toBe( + publishedFormEtag({ + publicId: "form_public_1", + revision: 4, + showAttribution: false, + }) + ); + }); +}); diff --git a/__tests__/usage-notifications.test.ts b/__tests__/usage-notifications.test.ts index c83135f..7fa6d56 100644 --- a/__tests__/usage-notifications.test.ts +++ b/__tests__/usage-notifications.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { crossedUsageThresholds } from "../lib/forms/usage-notifications"; +import { + crossedUsageThresholds, + sendUsageThresholdNotification, +} from "../lib/forms/usage-notifications"; describe("usage notification thresholds", () => { it("claims no notification below 80 percent", () => { @@ -17,4 +20,19 @@ describe("usage notification thresholds", () => { it("does not notify enterprise accounts with contract-defined capacity", () => { expect(crossedUsageThresholds({ used: 1_000_000, limit: null })).toEqual([]); }); + + it("keeps delivery retryable when email is not configured", async () => { + const originalKey = process.env.RESEND_API_KEY; + delete process.env.RESEND_API_KEY; + await expect( + sendUsageThresholdNotification({ + email: "owner@example.com", + threshold: 80, + used: 80, + limit: 100, + periodStart: "2026-09-01", + }) + ).rejects.toThrow("not configured"); + if (originalKey) process.env.RESEND_API_KEY = originalKey; + }); }); diff --git a/app/api/cron/forms-maintenance/route.ts b/app/api/cron/forms-maintenance/route.ts new file mode 100644 index 0000000..f957c65 --- /dev/null +++ b/app/api/cron/forms-maintenance/route.ts @@ -0,0 +1,16 @@ +import type { NextRequest } from "next/server"; +import { pruneFormRateBuckets } from "@/lib/forms/rate-limit"; +import { retryPendingUsageNotifications } from "@/lib/forms/usage-notifications"; + +export async function GET(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return new Response("Unauthorized", { status: 401 }); + } + + const [prunedRateBuckets, usageNotifications] = await Promise.all([ + pruneFormRateBuckets(), + retryPendingUsageNotifications(), + ]); + return Response.json({ success: true, prunedRateBuckets, usageNotifications }); +} diff --git a/app/api/cron/route.ts b/app/api/cron/route.ts index 93cbfa5..ad90060 100644 --- a/app/api/cron/route.ts +++ b/app/api/cron/route.ts @@ -1,6 +1,5 @@ import type { NextRequest } from "next/server"; import { clearLeadCount } from "@/lib/data/users"; -import { pruneFormRateBuckets } from "@/lib/forms/rate-limit"; /** * Cron job to clear lead count run through Vercel @@ -19,7 +18,6 @@ export async function GET(request: NextRequest) { } await clearLeadCount(); - const prunedRateBuckets = await pruneFormRateBuckets(); - return Response.json({ success: true, prunedRateBuckets }); + return Response.json({ success: true }); } diff --git a/app/api/public/forms/[publicId]/route.ts b/app/api/public/forms/[publicId]/route.ts index cd16edb..bcdf8e3 100644 --- a/app/api/public/forms/[publicId]/route.ts +++ b/app/api/public/forms/[publicId]/route.ts @@ -3,6 +3,7 @@ import { getPublishedForm } from "@/lib/data/forms"; import { isApprovedFormOrigin, publicCorsHeaders } from "@/lib/forms/public-access"; import { requestOrigin } from "@/lib/forms/origins"; import { publicFormsEnabled } from "@/lib/forms/feature-flags"; +import { publishedFormEtag } from "@/lib/forms/cache"; export async function GET( request: Request, @@ -26,7 +27,7 @@ export async function GET( (await isApprovedFormOrigin({ publicId, origin, placement: "wordpress" })) : false; const headers = publicCorsHeaders(origin, approved); - const etag = `W/\"${published.publicId}-${published.revision}\"`; + const etag = publishedFormEtag(published); headers.set("ETag", etag); headers.set( "Cache-Control", diff --git a/app/endpoints/[id]/page.tsx b/app/endpoints/[id]/page.tsx index 9ac7871..7b4c825 100644 --- a/app/endpoints/[id]/page.tsx +++ b/app/endpoints/[id]/page.tsx @@ -23,6 +23,7 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { getFormForEndpoint } from "@/lib/data/forms"; import { formsNavigationEnabled } from "@/lib/forms/feature-flags"; +import { isEndpointSchemaCompatible } from "@/lib/forms/starters"; const pageData = { title: "Endpoint", @@ -45,6 +46,7 @@ export default async function Page({ if (!endpointData || serverError) notFound(); const schema = endpointData?.schema as GeneralSchema[]; + const endpointSupportsForm = isEndpointSchemaCompatible(schema); const url = `https://app.router.so/api/endpoints/${endpointData.id}`; @@ -93,7 +95,8 @@ export default async function Page({
{`${pageData?.description}`}
- {formsNavigationEnabled() && ( + {formsNavigationEnabled() && + (attachedForm?.data || endpointSupportsForm) && (

diff --git a/app/forms/create/page.tsx b/app/forms/create/page.tsx index 29a4e7e..b531d68 100644 --- a/app/forms/create/page.tsx +++ b/app/forms/create/page.tsx @@ -4,6 +4,7 @@ import { Header } from "@/components/parts/header"; import { PageWrapper } from "@/components/parts/page-wrapper"; import { getEndpoints } from "@/lib/data/endpoints"; import { CreateForm } from "@/components/groups/forms/create-form"; +import { isEndpointSchemaCompatible } from "@/lib/forms/starters"; export default async function CreateFormPage({ searchParams, @@ -13,13 +14,24 @@ export default async function CreateFormPage({ const endpoints = await getEndpoints(); if (!endpoints?.data || endpoints.serverError) notFound(); const { endpointId } = await searchParams; + const compatibleEndpoints = endpoints.data.filter((endpoint) => + isEndpointSchemaCompatible(endpoint.schema) + ); + const initialEndpointId = compatibleEndpoints.some( + (endpoint) => endpoint.id === endpointId + ) + ? endpointId + : undefined; return ( <>

Start fresh, use a starter, or attach an endpoint
- + ); diff --git a/lib/data/endpoints.ts b/lib/data/endpoints.ts index 184cdae..f38d38a 100644 --- a/lib/data/endpoints.ts +++ b/lib/data/endpoints.ts @@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache"; import { db, Endpoint } from "../db"; import { endpoints, forms } from "../db/schema"; -import { eq, desc, and } from "drizzle-orm"; +import { eq, desc, and, isNotNull } from "drizzle-orm"; import { getErrorMessage } from "@/lib/helpers/error-message"; import { ActionError, authenticatedAction } from "./safe-action"; import { z } from "zod"; @@ -13,6 +13,25 @@ import { } from "./validations"; import { randomBytes } from "crypto"; import { redirect } from "next/navigation"; +import { invalidatePublishedForm } from "@/lib/forms/cache"; + +async function invalidateAttachedPublishedForm( + endpointId: string, + userId: string +): Promise { + const [attachedForm] = await db + .select({ publicId: forms.publicId }) + .from(forms) + .where( + and( + eq(forms.endpointId, endpointId), + eq(forms.userId, userId), + isNotNull(forms.publishedAt) + ) + ) + .limit(1); + if (attachedForm) invalidatePublishedForm(attachedForm.publicId); +} /** * Gets all endpoints for a user @@ -94,6 +113,7 @@ export const disableEndpoint = authenticatedAction .update(endpoints) .set({ enabled: false, updatedAt: new Date() }) .where(and(eq(endpoints.id, id), eq(endpoints.userId, userId))); + await invalidateAttachedPublishedForm(id, userId); revalidatePath("/endpoints"); }); @@ -109,6 +129,7 @@ export const enableEndpoint = authenticatedAction .update(endpoints) .set({ enabled: true, updatedAt: new Date() }) .where(and(eq(endpoints.id, id), eq(endpoints.userId, userId))); + await invalidateAttachedPublishedForm(id, userId); revalidatePath("/endpoints"); }); diff --git a/lib/data/forms.ts b/lib/data/forms.ts index 873acdb..cc525aa 100644 --- a/lib/data/forms.ts +++ b/lib/data/forms.ts @@ -16,11 +16,13 @@ import { import { ActionError, authenticatedAction } from "./safe-action"; import { compileEndpointSchema, + formDraftDefinitionV1Schema, formDefinitionV1Schema, type FormDefinitionV1, } from "@/lib/forms/definition"; import { getStarter, + isEndpointSchemaCompatible, seedDefinitionFromEndpoint, type StarterId, } from "@/lib/forms/starters"; @@ -49,8 +51,8 @@ const createFormInputSchema = z.object({ const saveFormDraftInputSchema = z.object({ id: z.string().min(1), expectedRevision: z.number().int().positive(), - name: z.string().trim().min(1).max(120), - definition: formDefinitionV1Schema, + name: z.string().max(120), + definition: formDraftDefinitionV1Schema, }); export const getForms = authenticatedAction.action( @@ -125,6 +127,11 @@ export const createForm = authenticatedAction .where(and(eq(endpoints.id, endpointId), eq(endpoints.userId, userId))) .limit(1); if (!endpoint) throw new ActionError("Endpoint not found."); + if (!isEndpointSchemaCompatible(endpoint.schema)) { + throw new ActionError( + "This endpoint contains fields that cannot be represented by a Router form." + ); + } const [existingForm] = await tx .select({ id: forms.id }) @@ -205,7 +212,9 @@ export const createForm = authenticatedAction export const saveFormDraft = authenticatedAction .schema(saveFormDraftInputSchema) .action(async ({ parsedInput, ctx: { userId } }) => { - const definition = formDefinitionV1Schema.parse(parsedInput.definition); + const definition = formDraftDefinitionV1Schema.parse( + parsedInput.definition + ) as FormDefinitionV1; const [updated] = await db .update(forms) .set({ @@ -247,6 +256,7 @@ export const publishForm = authenticatedAction throw new ActionError("Save the latest draft before publishing."); } + z.string().trim().min(1).max(120).parse(form.name); const definition = formDefinitionV1Schema.parse(form.draftDefinition); const compiledSchema = compileEndpointSchema(definition); const now = new Date(); @@ -265,11 +275,23 @@ export const publishForm = authenticatedAction unpublishedAt: null, updatedAt: now, }) - .where(eq(forms.id, form.id)) + .where( + and( + eq(forms.id, form.id), + eq(forms.userId, userId), + eq(forms.draftRevision, parsedInput.expectedDraftRevision), + eq(forms.publishedRevision, form.publishedRevision) + ) + ) .returning({ publicId: forms.publicId, publishedRevision: forms.publishedRevision, }); + if (!updated) { + throw new ActionError( + "This form changed while it was publishing. Reload and publish the latest draft." + ); + } return updated; }); diff --git a/lib/db/drizzle/0011_usage_notification_delivery_lease.sql b/lib/db/drizzle/0011_usage_notification_delivery_lease.sql new file mode 100644 index 0000000..08f1814 --- /dev/null +++ b/lib/db/drizzle/0011_usage_notification_delivery_lease.sql @@ -0,0 +1,2 @@ +ALTER TABLE "usagePeriod" ADD COLUMN "notifyingAt80" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "usagePeriod" ADD COLUMN "notifyingAt100" timestamp with time zone; \ No newline at end of file diff --git a/lib/db/drizzle/0012_usage_notification_pending_limits.sql b/lib/db/drizzle/0012_usage_notification_pending_limits.sql new file mode 100644 index 0000000..23deec7 --- /dev/null +++ b/lib/db/drizzle/0012_usage_notification_pending_limits.sql @@ -0,0 +1,2 @@ +ALTER TABLE "usagePeriod" ADD COLUMN "notificationLimit80" integer;--> statement-breakpoint +ALTER TABLE "usagePeriod" ADD COLUMN "notificationLimit100" integer; \ No newline at end of file diff --git a/lib/db/drizzle/meta/0011_snapshot.json b/lib/db/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..1f6ccc6 --- /dev/null +++ b/lib/db/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1268 @@ +{ + "id": "3625ac5f-32de-48ec-8885-a0c7b95ae6df", + "prevId": "3dac894e-9983-4349-be02-c957dd696ded", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.endpoint": { + "name": "endpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhookEnabled": { + "name": "webhookEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "emailNotify": { + "name": "emailNotify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook": { + "name": "webhook", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formEnabled": { + "name": "formEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "successUrl": { + "name": "successUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failUrl": { + "name": "failUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "endpoint_userId_user_id_fk": { + "name": "endpoint_userId_user_id_fk", + "tableFrom": "endpoint", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formOrigin": { + "name": "formOrigin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "formOriginKind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_origin_unique": { + "name": "form_origin_unique", + "columns": [ + { + "expression": "formId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formOrigin_formId_form_id_fk": { + "name": "formOrigin_formId_form_id_fk", + "tableFrom": "formOrigin", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "formOrigin_connectionId_wordpressConnection_id_fk": { + "name": "formOrigin_connectionId_wordpressConnection_id_fk", + "tableFrom": "formOrigin", + "tableTo": "wordpressConnection", + "columnsFrom": [ + "connectionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formPlacementMilestone": { + "name": "formPlacementMilestone", + "schema": "", + "columns": { + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "placement": { + "name": "placement", + "type": "formPlacement", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "firstLeadId": { + "name": "firstLeadId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "formPlacementMilestone_formId_form_id_fk": { + "name": "formPlacementMilestone_formId_form_id_fk", + "tableFrom": "formPlacementMilestone", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "formPlacementMilestone_formId_placement_pk": { + "name": "formPlacementMilestone_formId_placement_pk", + "columns": [ + "formId", + "placement" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formRateBucket": { + "name": "formRateBucket", + "schema": "", + "columns": { + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucketKey": { + "name": "bucketKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windowStart": { + "name": "windowStart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_rate_bucket_prune_idx": { + "name": "form_rate_bucket_prune_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formRateBucket_formId_form_id_fk": { + "name": "formRateBucket_formId_form_id_fk", + "tableFrom": "formRateBucket", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "formRateBucket_formId_bucketKey_windowStart_pk": { + "name": "formRateBucket_formId_bucketKey_windowStart_pk", + "columns": [ + "formId", + "bucketKey", + "windowStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachedToExistingEndpoint": { + "name": "attachedToExistingEndpoint", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "draftDefinition": { + "name": "draftDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "draftRevision": { + "name": "draftRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "publishedDefinition": { + "name": "publishedDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishedRevision": { + "name": "publishedRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unpublishedAt": { + "name": "unpublishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_endpoint_unique": { + "name": "form_endpoint_unique", + "columns": [ + { + "expression": "endpointId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_public_id_unique": { + "name": "form_public_id_unique", + "columns": [ + { + "expression": "publicId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_owner_updated_idx": { + "name": "form_owner_updated_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_userId_user_id_fk": { + "name": "form_userId_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_endpointId_endpoint_id_fk": { + "name": "form_endpointId_endpoint_id_fk", + "tableFrom": "form", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead": { + "name": "lead", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formRevision": { + "name": "formRevision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "placement": { + "name": "placement", + "type": "formPlacement", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "lead_endpointId_endpoint_id_fk": { + "name": "lead_endpointId_endpoint_id_fk", + "tableFrom": "lead", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_formId_form_id_fk": { + "name": "lead_formId_form_id_fk", + "tableFrom": "lead", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log": { + "name": "log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "logType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "postType": { + "name": "postType", + "type": "logPostType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "log_endpointId_endpoint_id_fk": { + "name": "log_endpointId_endpoint_id_fk", + "tableFrom": "log", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usagePeriod": { + "name": "usagePeriod", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "periodStart": { + "name": "periodStart", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notifiedAt80": { + "name": "notifiedAt80", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifiedAt100": { + "name": "notifiedAt100", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifyingAt80": { + "name": "notifyingAt80", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifyingAt100": { + "name": "notifyingAt100", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "usagePeriod_userId_user_id_fk": { + "name": "usagePeriod_userId_user_id_fk", + "tableFrom": "usagePeriod", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "usagePeriod_userId_periodStart_pk": { + "name": "usagePeriod_userId_periodStart_pk", + "columns": [ + "userId", + "periodStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "plan": { + "name": "plan", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeCurrentPeriodEnd": { + "name": "stripeCurrentPeriodEnd", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripeCancelAtPeriodEnd": { + "name": "stripeCancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "legacyPriceMigrationRequired": { + "name": "legacyPriceMigrationRequired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wordpressConnection": { + "name": "wordpressConnection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteOrigin": { + "name": "siteOrigin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteName": { + "name": "siteName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wordpress_connection_token_hash_unique": { + "name": "wordpress_connection_token_hash_unique", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wordpress_connection_owner_site_idx": { + "name": "wordpress_connection_owner_site_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "siteOrigin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wordpressConnection_userId_user_id_fk": { + "name": "wordpressConnection_userId_user_id_fk", + "tableFrom": "wordpressConnection", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.formOriginKind": { + "name": "formOriginKind", + "schema": "public", + "values": [ + "embed", + "wordpress" + ] + }, + "public.formPlacement": { + "name": "formPlacement", + "schema": "public", + "values": [ + "headless", + "legacy_html", + "hosted", + "embed", + "wordpress" + ] + }, + "public.logPostType": { + "name": "logPostType", + "schema": "public", + "values": [ + "http", + "form", + "webhook", + "email" + ] + }, + "public.logType": { + "name": "logType", + "schema": "public", + "values": [ + "success", + "error" + ] + }, + "public.plan": { + "name": "plan", + "schema": "public", + "values": [ + "free", + "lite", + "pro", + "business", + "enterprise" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/drizzle/meta/0012_snapshot.json b/lib/db/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..a5260c3 --- /dev/null +++ b/lib/db/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1280 @@ +{ + "id": "e5e29dcf-281d-4cc0-a557-382655737666", + "prevId": "3625ac5f-32de-48ec-8885-a0c7b95ae6df", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.endpoint": { + "name": "endpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhookEnabled": { + "name": "webhookEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "emailNotify": { + "name": "emailNotify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook": { + "name": "webhook", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formEnabled": { + "name": "formEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "successUrl": { + "name": "successUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failUrl": { + "name": "failUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "endpoint_userId_user_id_fk": { + "name": "endpoint_userId_user_id_fk", + "tableFrom": "endpoint", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formOrigin": { + "name": "formOrigin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "formOriginKind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_origin_unique": { + "name": "form_origin_unique", + "columns": [ + { + "expression": "formId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formOrigin_formId_form_id_fk": { + "name": "formOrigin_formId_form_id_fk", + "tableFrom": "formOrigin", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "formOrigin_connectionId_wordpressConnection_id_fk": { + "name": "formOrigin_connectionId_wordpressConnection_id_fk", + "tableFrom": "formOrigin", + "tableTo": "wordpressConnection", + "columnsFrom": [ + "connectionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formPlacementMilestone": { + "name": "formPlacementMilestone", + "schema": "", + "columns": { + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "placement": { + "name": "placement", + "type": "formPlacement", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "firstLeadId": { + "name": "firstLeadId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "formPlacementMilestone_formId_form_id_fk": { + "name": "formPlacementMilestone_formId_form_id_fk", + "tableFrom": "formPlacementMilestone", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "formPlacementMilestone_formId_placement_pk": { + "name": "formPlacementMilestone_formId_placement_pk", + "columns": [ + "formId", + "placement" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.formRateBucket": { + "name": "formRateBucket", + "schema": "", + "columns": { + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucketKey": { + "name": "bucketKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "windowStart": { + "name": "windowStart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_rate_bucket_prune_idx": { + "name": "form_rate_bucket_prune_idx", + "columns": [ + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "formRateBucket_formId_form_id_fk": { + "name": "formRateBucket_formId_form_id_fk", + "tableFrom": "formRateBucket", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "formRateBucket_formId_bucketKey_windowStart_pk": { + "name": "formRateBucket_formId_bucketKey_windowStart_pk", + "columns": [ + "formId", + "bucketKey", + "windowStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachedToExistingEndpoint": { + "name": "attachedToExistingEndpoint", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "draftDefinition": { + "name": "draftDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "draftRevision": { + "name": "draftRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "publishedDefinition": { + "name": "publishedDefinition", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishedRevision": { + "name": "publishedRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unpublishedAt": { + "name": "unpublishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_endpoint_unique": { + "name": "form_endpoint_unique", + "columns": [ + { + "expression": "endpointId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_public_id_unique": { + "name": "form_public_id_unique", + "columns": [ + { + "expression": "publicId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_owner_updated_idx": { + "name": "form_owner_updated_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_userId_user_id_fk": { + "name": "form_userId_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_endpointId_endpoint_id_fk": { + "name": "form_endpointId_endpoint_id_fk", + "tableFrom": "form", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead": { + "name": "lead", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formId": { + "name": "formId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "formRevision": { + "name": "formRevision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "placement": { + "name": "placement", + "type": "formPlacement", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "lead_endpointId_endpoint_id_fk": { + "name": "lead_endpointId_endpoint_id_fk", + "tableFrom": "lead", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_formId_form_id_fk": { + "name": "lead_formId_form_id_fk", + "tableFrom": "lead", + "tableTo": "form", + "columnsFrom": [ + "formId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log": { + "name": "log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "endpointId": { + "name": "endpointId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "logType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "postType": { + "name": "postType", + "type": "logPostType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "log_endpointId_endpoint_id_fk": { + "name": "log_endpointId_endpoint_id_fk", + "tableFrom": "log", + "tableTo": "endpoint", + "columnsFrom": [ + "endpointId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usagePeriod": { + "name": "usagePeriod", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "periodStart": { + "name": "periodStart", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notifiedAt80": { + "name": "notifiedAt80", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifiedAt100": { + "name": "notifiedAt100", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifyingAt80": { + "name": "notifyingAt80", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notifyingAt100": { + "name": "notifyingAt100", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notificationLimit80": { + "name": "notificationLimit80", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notificationLimit100": { + "name": "notificationLimit100", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "usagePeriod_userId_user_id_fk": { + "name": "usagePeriod_userId_user_id_fk", + "tableFrom": "usagePeriod", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "usagePeriod_userId_periodStart_pk": { + "name": "usagePeriod_userId_periodStart_pk", + "columns": [ + "userId", + "periodStart" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "leadCount": { + "name": "leadCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "plan": { + "name": "plan", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeCurrentPeriodEnd": { + "name": "stripeCurrentPeriodEnd", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripeCancelAtPeriodEnd": { + "name": "stripeCancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "legacyPriceMigrationRequired": { + "name": "legacyPriceMigrationRequired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wordpressConnection": { + "name": "wordpressConnection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteOrigin": { + "name": "siteOrigin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "siteName": { + "name": "siteName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wordpress_connection_token_hash_unique": { + "name": "wordpress_connection_token_hash_unique", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wordpress_connection_owner_site_idx": { + "name": "wordpress_connection_owner_site_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "siteOrigin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wordpressConnection_userId_user_id_fk": { + "name": "wordpressConnection_userId_user_id_fk", + "tableFrom": "wordpressConnection", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.formOriginKind": { + "name": "formOriginKind", + "schema": "public", + "values": [ + "embed", + "wordpress" + ] + }, + "public.formPlacement": { + "name": "formPlacement", + "schema": "public", + "values": [ + "headless", + "legacy_html", + "hosted", + "embed", + "wordpress" + ] + }, + "public.logPostType": { + "name": "logPostType", + "schema": "public", + "values": [ + "http", + "form", + "webhook", + "email" + ] + }, + "public.logType": { + "name": "logType", + "schema": "public", + "values": [ + "success", + "error" + ] + }, + "public.plan": { + "name": "plan", + "schema": "public", + "values": [ + "free", + "lite", + "pro", + "business", + "enterprise" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/drizzle/meta/_journal.json b/lib/db/drizzle/meta/_journal.json index 5431b58..162e642 100644 --- a/lib/db/drizzle/meta/_journal.json +++ b/lib/db/drizzle/meta/_journal.json @@ -78,6 +78,20 @@ "when": 1788300568939, "tag": "0010_placement_first_lead_analytics", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1788313995410, + "tag": "0011_usage_notification_delivery_lease", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1788314505657, + "tag": "0012_usage_notification_pending_limits", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 137b1c1..8efe84d 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -251,6 +251,10 @@ export const usagePeriods = pgTable( leadCount: integer("leadCount").notNull().default(0), notifiedAt80: timestamp("notifiedAt80", { withTimezone: true }), notifiedAt100: timestamp("notifiedAt100", { withTimezone: true }), + notifyingAt80: timestamp("notifyingAt80", { withTimezone: true }), + notifyingAt100: timestamp("notifyingAt100", { withTimezone: true }), + notificationLimit80: integer("notificationLimit80"), + notificationLimit100: integer("notificationLimit100"), updatedAt: timestamp("updatedAt", { withTimezone: true }) .notNull() .defaultNow(), diff --git a/lib/forms/cache.ts b/lib/forms/cache.ts index 955271d..4ad3281 100644 --- a/lib/forms/cache.ts +++ b/lib/forms/cache.ts @@ -3,6 +3,14 @@ import { revalidateTag } from "next/cache"; export const publishedFormCacheTag = (publicId: string) => `published-form:${publicId}`; +export function publishedFormEtag(input: { + publicId: string; + revision: number; + showAttribution: boolean; +}): string { + return `W/"${input.publicId}-${input.revision}-${input.showAttribution ? "attributed" : "unbranded"}"`; +} + export function invalidatePublishedForm(publicId: string): void { revalidateTag(publishedFormCacheTag(publicId)); } diff --git a/lib/forms/definition.ts b/lib/forms/definition.ts index 91a6bd1..10317f8 100644 --- a/lib/forms/definition.ts +++ b/lib/forms/definition.ts @@ -1,5 +1,9 @@ import { z } from "zod"; import validator from "validator"; +import { + numberSchemaWithConstraints, + stringSchemaWithLength, +} from "./field-constraints"; const fieldIdSchema = z .string() @@ -163,7 +167,13 @@ const completionSchema = z.discriminatedUnion("type", [ url: z .string() .url() - .refine((value) => new URL(value).protocol === "https:", { + .refine((value) => { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } + }, { message: "Redirect URLs must use HTTPS.", }), }), @@ -218,6 +228,91 @@ export const formDefinitionV1Schema = z }); }); +const draftOptionSchema = z.object({ + id: z.string().max(80), + label: z.string().max(120), + value: z.string().max(120), +}); + +const draftValidationSchema = z + .object({ + minLength: z.number().finite().optional(), + maxLength: z.number().finite().optional(), + min: z.union([z.number().finite(), z.string().max(100)]).optional(), + max: z.union([z.number().finite(), z.string().max(100)]).optional(), + step: z.number().finite().optional(), + minSelections: z.number().finite().optional(), + maxSelections: z.number().finite().optional(), + }) + .optional(); + +const draftFieldSchema = z + .object({ + id: z.string().max(80), + key: z.string().max(80), + kind: z.enum([ + "text", + "email", + "phone", + "url", + "date", + "number", + "textarea", + "select", + "radio", + "checkbox", + "checkbox-group", + "yes-no", + "switch", + "slider", + ]), + label: z.string().max(160), + helpText: z.string().max(500).optional(), + required: z.boolean(), + placeholder: z.string().max(200).optional(), + defaultValue: z + .union([ + z.string().max(10_000), + z.number().finite(), + z.boolean(), + z.array(z.string().max(120)).max(100), + ]) + .optional(), + options: z.array(draftOptionSchema).max(100).optional(), + rows: z.number().finite().optional(), + validation: draftValidationSchema, + }) + .superRefine((field, context) => { + if ( + (field.kind === "select" || + field.kind === "radio" || + field.kind === "checkbox-group") && + !field.options + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["options"], + message: "Choice fields require options.", + }); + } + }); + +/** + * Drafts preserve safe editor state even while fields are temporarily incomplete. + * Publishing always reparses the snapshot with formDefinitionV1Schema. + */ +export const formDraftDefinitionV1Schema = z.object({ + version: z.literal(1), + title: z.string().max(120), + description: z.string().max(600).optional(), + fields: z.array(draftFieldSchema).max(100), + submitLabel: z.string().max(80), + completion: z.discriminatedUnion("type", [ + z.object({ type: z.literal("message"), message: z.string().max(1_000) }), + z.object({ type: z.literal("redirect"), url: z.string().max(2_048) }), + ]), +}); + export type FormDefinitionV1 = z.infer; export type FormFieldV1 = z.infer; export type FormCompletionV1 = z.infer; @@ -332,24 +427,34 @@ function schemaForField(field: FormFieldV1): z.ZodTypeAny { switch (field.kind) { case "text": case "textarea": { - let schema = z.string(); - if (field.validation?.minLength !== undefined) { - schema = schema.min(field.validation.minLength, `Enter at least ${field.validation.minLength} characters.`); - } - if (field.validation?.maxLength !== undefined) { - schema = schema.max(field.validation.maxLength, `Enter no more than ${field.validation.maxLength} characters.`); - } + const schema = stringSchemaWithLength(field.validation, { + min: field.validation?.minLength !== undefined + ? `Enter at least ${field.validation.minLength} characters.` + : undefined, + max: field.validation?.maxLength !== undefined + ? `Enter no more than ${field.validation.maxLength} characters.` + : undefined, + }); return optionalString(schema, field.required); } case "email": - return optionalString(z.string().email("Enter a valid email address."), field.required); + return optionalString( + stringSchemaWithLength(field.validation).email("Enter a valid email address."), + field.required + ); case "phone": return optionalString( - z.string().refine((value) => validator.isMobilePhone(value), "Enter a valid phone number."), + stringSchemaWithLength(field.validation).refine( + (value) => validator.isMobilePhone(value), + "Enter a valid phone number." + ), field.required ); case "url": - return optionalString(z.string().url("Enter a valid URL."), field.required); + return optionalString( + stringSchemaWithLength(field.validation).url("Enter a valid URL."), + field.required + ); case "date": { let schema: z.ZodType = z.string().date("Enter a valid date."); if (field.validation?.min) { @@ -362,9 +467,10 @@ function schemaForField(field: FormFieldV1): z.ZodTypeAny { } case "number": case "slider": { - let schema = z.coerce.number().finite("Enter a valid number."); - if (field.validation?.min !== undefined) schema = schema.min(field.validation.min); - if (field.validation?.max !== undefined) schema = schema.max(field.validation.max); + const schema = numberSchemaWithConstraints( + z.coerce.number().finite("Enter a valid number."), + field.validation + ); return field.required ? schema : z.preprocess((value) => (value === "" ? undefined : value), schema.optional()); } case "select": diff --git a/lib/forms/endpoint-schema.ts b/lib/forms/endpoint-schema.ts index 887b0e6..bce6b43 100644 --- a/lib/forms/endpoint-schema.ts +++ b/lib/forms/endpoint-schema.ts @@ -1,6 +1,10 @@ import { z } from "zod"; import validator from "validator"; import type { CompiledEndpointField } from "./definition"; +import { + numberSchemaWithConstraints, + stringSchemaWithLength, +} from "./field-constraints"; export type LegacyEndpointField = { key: string; @@ -34,15 +38,14 @@ function fieldSchema(field: CompatibleEndpointField): z.ZodTypeAny { switch (field.value) { case "email": - schema = z.string().email("Not a valid email."); + schema = stringSchemaWithLength(constraints).email("Not a valid email."); break; case "phone": - schema = z - .string() + schema = stringSchemaWithLength(constraints) .refine((value) => validator.isMobilePhone(value), "Not a valid phone number."); break; case "url": - schema = z.string().url("Not a valid URL."); + schema = stringSchemaWithLength(constraints).url("Not a valid URL."); break; case "zip_code": schema = z.string().length(5, "Not a valid zip code."); @@ -59,10 +62,11 @@ function fieldSchema(field: CompatibleEndpointField): z.ZodTypeAny { break; } case "number": { - let numberSchema = z.number().finite(); - if (typeof constraints?.min === "number") numberSchema = numberSchema.min(constraints.min); - if (typeof constraints?.max === "number") numberSchema = numberSchema.max(constraints.max); - schema = numberSchema; + schema = numberSchemaWithConstraints(z.number().finite(), { + min: typeof constraints?.min === "number" ? constraints.min : undefined, + max: typeof constraints?.max === "number" ? constraints.max : undefined, + step: constraints?.step, + }); break; } case "boolean": @@ -82,10 +86,12 @@ function fieldSchema(field: CompatibleEndpointField): z.ZodTypeAny { } case "string": default: { - let stringSchema = z.string(); - if (constraints?.minLength !== undefined) stringSchema = stringSchema.min(constraints.minLength); - else if (isLegacyField(field)) stringSchema = stringSchema.min(2, "Not a valid string."); - if (constraints?.maxLength !== undefined) stringSchema = stringSchema.max(constraints.maxLength); + const stringSchema = stringSchemaWithLength( + constraints?.minLength === undefined && isLegacyField(field) + ? { ...constraints, minLength: 2 } + : constraints, + { min: isLegacyField(field) ? "Not a valid string." : undefined } + ); if (constraints?.allowedValues) { schema = stringSchema.refine( (value) => constraints.allowedValues!.includes(value), diff --git a/lib/forms/field-constraints.ts b/lib/forms/field-constraints.ts new file mode 100644 index 0000000..347880c --- /dev/null +++ b/lib/forms/field-constraints.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +export type StringLengthConstraints = { + minLength?: number; + maxLength?: number; +}; + +export type NumberConstraints = { + min?: number; + max?: number; + step?: number; +}; + +export function stringSchemaWithLength( + constraints?: StringLengthConstraints, + messages: { min?: string; max?: string } = {} +): z.ZodString { + let schema = z.string(); + if (constraints?.minLength !== undefined) { + schema = schema.min(constraints.minLength, messages.min); + } + if (constraints?.maxLength !== undefined) { + schema = schema.max(constraints.maxLength, messages.max); + } + return schema; +} + +export function isNumberStepAligned( + value: number, + constraints?: NumberConstraints +): boolean { + if (constraints?.step === undefined) return true; + const base = constraints.min ?? 0; + const steps = (value - base) / constraints.step; + return Math.abs(steps - Math.round(steps)) <= 1e-9; +} + +export function numberSchemaWithConstraints( + schema: z.ZodNumber, + constraints?: NumberConstraints, + stepMessage = "Choose a valid step value." +): z.ZodType { + let bounded = schema; + if (constraints?.min !== undefined) bounded = bounded.min(constraints.min); + if (constraints?.max !== undefined) bounded = bounded.max(constraints.max); + return bounded.refine( + (value) => isNumberStepAligned(value, constraints), + stepMessage + ); +} diff --git a/lib/forms/lead-acceptance.ts b/lib/forms/lead-acceptance.ts index b54eeb3..a7570de 100644 --- a/lib/forms/lead-acceptance.ts +++ b/lib/forms/lead-acceptance.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, isNotNull, isNull, sql } from "drizzle-orm"; +import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { db } from "@/lib/db"; import { @@ -22,7 +22,7 @@ import { import type { FormPlacement } from "./submission-token"; import { crossedUsageThresholds, - sendUsageThresholdNotification, + deliverUsageThresholdNotification, type UsageThreshold, } from "./usage-notifications"; @@ -229,7 +229,11 @@ export async function acceptLead( updatedAt: now, }, }) - .returning({ leadCount: usagePeriods.leadCount }); + .returning({ + leadCount: usagePeriods.leadCount, + notifiedAt80: usagePeriods.notifiedAt80, + notifiedAt100: usagePeriods.notifiedAt100, + }); const graceLimit = entitlement.monthlyLeads === null @@ -241,31 +245,31 @@ export async function acceptLead( ); } - const usageNotifications: UsageThreshold[] = []; - for (const threshold of crossedUsageThresholds({ + const usageNotifications: UsageThreshold[] = crossedUsageThresholds({ used: usage.leadCount, limit: entitlement.monthlyLeads, - })) { - const notificationColumn = - threshold === 80 ? usagePeriods.notifiedAt80 : usagePeriods.notifiedAt100; - const [claimed] = await tx + }).filter((threshold) => + threshold === 80 ? usage.notifiedAt80 === null : usage.notifiedAt100 === null + ); + for (const threshold of usageNotifications) { + const notificationLimitColumn = + threshold === 80 + ? usagePeriods.notificationLimit80 + : usagePeriods.notificationLimit100; + await tx .update(usagePeriods) - .set(threshold === 80 ? { notifiedAt80: now } : { notifiedAt100: now }) + .set( + threshold === 80 + ? { notificationLimit80: entitlement.monthlyLeads } + : { notificationLimit100: entitlement.monthlyLeads } + ) .where( and( eq(usagePeriods.userId, row.owner.id), eq(usagePeriods.periodStart, periodStart), - isNull(notificationColumn), - gte( - usagePeriods.leadCount, - threshold === 80 - ? Math.ceil(entitlement.monthlyLeads! * 0.8) - : entitlement.monthlyLeads! - ) + isNull(notificationLimitColumn) ) - ) - .returning({ userId: usagePeriods.userId }); - if (claimed) usageNotifications.push(threshold); + ); } const [lead] = await tx @@ -316,8 +320,6 @@ export async function acceptLead( formId, firstPlacement: Boolean(firstPlacement), periodStart, - leadCount: usage.leadCount, - monthlyLeadLimit: entitlement.monthlyLeads, usageNotifications, webhook: row.endpoint.webhookEnabled && row.endpoint.webhook @@ -337,19 +339,17 @@ export async function acceptLead( }, }); } - if (accepted.monthlyLeadLimit !== null) { - for (const threshold of accepted.usageNotifications) { - try { - await sendUsageThresholdNotification({ - email: accepted.ownerEmail, - threshold, - used: accepted.leadCount, - limit: accepted.monthlyLeadLimit, - periodStart: accepted.periodStart, - }); - } catch (error) { - console.error(`Could not send ${threshold}% usage notification:`, error); - } + for (const threshold of accepted.usageNotifications) { + try { + await deliverUsageThresholdNotification({ + userId: accepted.ownerId, + email: accepted.ownerEmail, + threshold, + periodStart: accepted.periodStart, + now, + }); + } catch (error) { + console.error(`Could not send ${threshold}% usage notification:`, error); } } revalidatePath("/"); diff --git a/lib/forms/starters.ts b/lib/forms/starters.ts index 683b386..ff8234a 100644 --- a/lib/forms/starters.ts +++ b/lib/forms/starters.ts @@ -1,4 +1,8 @@ -import type { FormDefinitionV1 } from "./definition"; +import type { + CompiledEndpointField, + FormDefinitionV1, + FormFieldV1, +} from "./definition"; export type StarterId = "blank" | "contact" | "lead-capture" | "feedback" | "newsletter"; @@ -99,41 +103,178 @@ export function getStarter(id: StarterId): FormDefinitionV1 { return structuredClone(FORM_STARTERS[id]); } +type EndpointSeedField = { + key: string; + value: string; + required?: boolean; + constraints?: CompiledEndpointField["constraints"]; +}; + +const directlyRepresentableEndpointTypes = new Set([ + "email", + "phone", + "url", + "date", + "number", + "boolean", + "string", + "zip_code", +]); + +function hasUsableAllowedValues(field: EndpointSeedField): boolean { + const values = field.constraints?.allowedValues; + return Boolean( + values?.length && + new Set(values).size === values.length && + values.every((value) => value.trim().length > 0 && value.length <= 120) + ); +} + +export function isEndpointSchemaCompatible(schema: EndpointSeedField[]): boolean { + const keys = new Set(); + return schema.every((field) => { + if ( + field.key.length > 80 || + !/^[A-Za-z][A-Za-z0-9_]*$/.test(field.key) || + keys.has(field.key) + ) { + return false; + } + keys.add(field.key); + if (field.value === "string_array") return hasUsableAllowedValues(field); + if (!directlyRepresentableEndpointTypes.has(field.value)) return false; + if (field.value === "string" && field.constraints?.allowedValues) { + return hasUsableAllowedValues(field); + } + return true; + }); +} + +function endpointOptions( + field: EndpointSeedField, + fieldId: string +): Array<{ id: string; label: string; value: string }> { + return field.constraints!.allowedValues!.map((value, optionIndex) => ({ + id: `${fieldId}_option_${optionIndex + 1}`, + label: value, + value, + })); +} + +function seedField( + field: EndpointSeedField, + index: number, + id: string, + key: string, + label: string +): FormFieldV1 { + const base = { id, key, label, required: field.required ?? true }; + const constraints = field.constraints; + + if (field.value === "string_array") { + return { + ...base, + kind: "checkbox-group", + options: endpointOptions(field, id), + validation: { + ...(constraints?.minItems !== undefined + ? { minSelections: constraints.minItems } + : {}), + ...(constraints?.maxItems !== undefined + ? { maxSelections: constraints.maxItems } + : {}), + }, + }; + } + if (field.value === "string" && constraints?.allowedValues) { + return { + ...base, + kind: "select", + options: endpointOptions(field, id), + }; + } + if (field.value === "zip_code") { + return { + ...base, + kind: "text", + validation: { minLength: 5, maxLength: 5 }, + }; + } + if ( + field.value === "email" || + field.value === "phone" || + field.value === "url" || + field.value === "string" + ) { + const legacyStringMinimum = + field.value === "string" && + field.required === undefined && + constraints?.minLength === undefined + ? 2 + : undefined; + return { + ...base, + kind: field.value === "string" ? "text" : field.value, + ...(legacyStringMinimum !== undefined || + constraints?.minLength !== undefined || + constraints?.maxLength !== undefined + ? { + validation: { + ...(legacyStringMinimum !== undefined + ? { minLength: legacyStringMinimum } + : constraints?.minLength !== undefined + ? { minLength: constraints.minLength } + : {}), + ...(constraints?.maxLength !== undefined + ? { maxLength: constraints.maxLength } + : {}), + }, + } + : {}), + }; + } + if (field.value === "number") { + return { + ...base, + kind: "number", + validation: { + ...(typeof constraints?.min === "number" ? { min: constraints.min } : {}), + ...(typeof constraints?.max === "number" ? { max: constraints.max } : {}), + ...(constraints?.step !== undefined ? { step: constraints.step } : {}), + }, + }; + } + if (field.value === "date") { + return { + ...base, + kind: "date", + validation: { + ...(typeof constraints?.min === "string" ? { min: constraints.min } : {}), + ...(typeof constraints?.max === "string" ? { max: constraints.max } : {}), + }, + }; + } + if (field.value === "boolean") return { ...base, kind: "yes-no" }; + + throw new Error(`Endpoint field ${index + 1} cannot be represented by a Router form.`); +} + export function seedDefinitionFromEndpoint( name: string, - schema: Array<{ key: string; value: string; required?: boolean }> + schema: EndpointSeedField[] ): FormDefinitionV1 { - const usedKeys = new Set(); + if (!isEndpointSchemaCompatible(schema)) { + throw new Error("This endpoint schema cannot be represented by a Router form."); + } return { version: 1, title: name, fields: schema.map((field, index) => { - const cleaned = field.key.replace(/[^A-Za-z0-9_]/g, "_"); - const baseKey = /^[A-Za-z]/.test(cleaned) - ? cleaned - : `field_${cleaned || index + 1}`; - let key = baseKey; - let suffix = 2; - while (usedKeys.has(key)) key = `${baseKey}_${suffix++}`; - usedKeys.add(key); - return { - id: `imported_${index}_${baseKey}`, - key, - kind: - field.value === "email" || - field.value === "phone" || - field.value === "url" || - field.value === "date" || - field.value === "number" - ? field.value - : field.value === "boolean" - ? "yes-no" - : "text", - label: field.key - .replace(/[_-]+/g, " ") - .replace(/^./, (character) => character.toUpperCase()), - required: field.required ?? true, - }; + const id = `imported_field_${index + 1}`; + const label = field.key + .replace(/[_-]+/g, " ") + .replace(/^./, (character) => character.toUpperCase()); + return seedField(field, index, id, field.key, label); }), submitLabel: "Submit", completion, diff --git a/lib/forms/usage-notifications.ts b/lib/forms/usage-notifications.ts index 076785d..64d8910 100644 --- a/lib/forms/usage-notifications.ts +++ b/lib/forms/usage-notifications.ts @@ -1,3 +1,7 @@ +import { createHash } from "node:crypto"; +import { and, eq, isNotNull, isNull, lt, or } from "drizzle-orm"; +import { db } from "../db"; +import { usagePeriods, users } from "../db/schema"; import { getResend } from "../utils/resend"; export type UsageThreshold = 80 | 100; @@ -19,8 +23,11 @@ export async function sendUsageThresholdNotification(input: { used: number; limit: number; periodStart: string; + idempotencyKey?: string; }): Promise { - if (!process.env.RESEND_API_KEY) return; + if (!process.env.RESEND_API_KEY) { + throw new Error("RESEND_API_KEY is not configured."); + } const appUrl = process.env.ROUTER_APP_URL || "https://app.router.so"; const subject = @@ -32,10 +39,169 @@ export async function sendUsageThresholdNotification(input: { ? "Router will continue accepting leads through 110% of your allowance before pausing new submissions." : "No action is required yet. You can review usage or choose a larger plan at any time."; - await getResend().emails.send({ - from: process.env.ROUTER_EMAIL_FROM || "info@router.so", - to: [input.email], - subject, - text: `${subject}\n\n${input.used.toLocaleString()} of ${input.limit.toLocaleString()} leads have been accepted for the UTC month beginning ${input.periodStart}. ${graceMessage}\n\nReview usage: ${appUrl}/upgrade\n`, - }); + const result = await getResend().emails.send( + { + from: process.env.ROUTER_EMAIL_FROM || "info@router.so", + to: [input.email], + subject, + text: `${subject}\n\n${input.used.toLocaleString()} of ${input.limit.toLocaleString()} leads have been accepted for the UTC month beginning ${input.periodStart}. ${graceMessage}\n\nReview usage: ${appUrl}/upgrade\n`, + }, + input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined + ); + if (result.error) throw new Error(result.error.message); +} + +const notificationClaimLeaseMs = 15 * 60 * 1_000; + +export function usageNotificationIdempotencyKey(input: { + userId: string; + periodStart: string; + threshold: UsageThreshold; +}): string { + return `router-usage-${createHash("sha256") + .update(`${input.userId}:${input.periodStart}:${input.threshold}`) + .digest("base64url")}`; +} + +export async function deliverUsageThresholdNotification(input: { + userId: string; + email: string; + threshold: UsageThreshold; + periodStart: string; + now?: Date; +}): Promise { + const now = input.now ?? new Date(); + const staleClaim = new Date(now.getTime() - notificationClaimLeaseMs); + const notifiedColumn = + input.threshold === 80 ? usagePeriods.notifiedAt80 : usagePeriods.notifiedAt100; + const notifyingColumn = + input.threshold === 80 ? usagePeriods.notifyingAt80 : usagePeriods.notifyingAt100; + const limitColumn = + input.threshold === 80 + ? usagePeriods.notificationLimit80 + : usagePeriods.notificationLimit100; + + const [claimed] = await db + .update(usagePeriods) + .set( + input.threshold === 80 + ? { notifyingAt80: now } + : { notifyingAt100: now } + ) + .where( + and( + eq(usagePeriods.userId, input.userId), + eq(usagePeriods.periodStart, input.periodStart), + isNull(notifiedColumn), + isNotNull(limitColumn), + or(isNull(notifyingColumn), lt(notifyingColumn, staleClaim)), + ) + ) + .returning({ used: usagePeriods.leadCount, limit: limitColumn }); + if (!claimed || claimed.limit === null) return false; + + try { + await sendUsageThresholdNotification({ + email: input.email, + threshold: input.threshold, + used: claimed.used, + limit: claimed.limit, + periodStart: input.periodStart, + idempotencyKey: usageNotificationIdempotencyKey(input), + }); + await db + .update(usagePeriods) + .set( + input.threshold === 80 + ? { notifiedAt80: new Date(), notifyingAt80: null } + : { notifiedAt100: new Date(), notifyingAt100: null } + ) + .where( + and( + eq(usagePeriods.userId, input.userId), + eq(usagePeriods.periodStart, input.periodStart), + isNull(notifiedColumn), + eq(notifyingColumn, now) + ) + ); + return true; + } catch (error) { + await db + .update(usagePeriods) + .set( + input.threshold === 80 + ? { notifyingAt80: null } + : { notifyingAt100: null } + ) + .where( + and( + eq(usagePeriods.userId, input.userId), + eq(usagePeriods.periodStart, input.periodStart), + isNull(notifiedColumn), + eq(notifyingColumn, now) + ) + ); + throw error; + } +} + +export async function retryPendingUsageNotifications( + now = new Date() +): Promise<{ attempted: number; delivered: number }> { + const rows = await db + .select({ + userId: usagePeriods.userId, + email: users.email, + periodStart: usagePeriods.periodStart, + notifiedAt80: usagePeriods.notifiedAt80, + notifiedAt100: usagePeriods.notifiedAt100, + notificationLimit80: usagePeriods.notificationLimit80, + notificationLimit100: usagePeriods.notificationLimit100, + }) + .from(usagePeriods) + .innerJoin(users, eq(usagePeriods.userId, users.id)) + .where( + or( + and( + isNull(usagePeriods.notifiedAt80), + isNotNull(usagePeriods.notificationLimit80) + ), + and( + isNull(usagePeriods.notifiedAt100), + isNotNull(usagePeriods.notificationLimit100) + ) + ) + ) + .limit(1_000); + + let attempted = 0; + let delivered = 0; + for (const row of rows) { + const thresholds: UsageThreshold[] = []; + if (row.notifiedAt80 === null && row.notificationLimit80 !== null) { + thresholds.push(80); + } + if (row.notifiedAt100 === null && row.notificationLimit100 !== null) { + thresholds.push(100); + } + for (const threshold of thresholds) { + attempted += 1; + try { + if ( + await deliverUsageThresholdNotification({ + userId: row.userId, + email: row.email, + threshold, + periodStart: row.periodStart, + now, + }) + ) { + delivered += 1; + } + } catch (error) { + console.error(`Could not retry ${threshold}% usage notification:`, error); + } + } + } + return { attempted, delivered }; } diff --git a/public/embed/v1.js b/public/embed/v1.js index dd85387..9a9609a 100644 --- a/public/embed/v1.js +++ b/public/embed/v1.js @@ -10,6 +10,7 @@ var script = document.currentScript; var apiBase = script && script.src ? new URL(script.src, window.location.href).origin : "https://forms.router.so"; var styleId = "router-forms-v1-styles"; + var mountSequence = 0; function installStyles() { if (document.getElementById(styleId)) return; @@ -56,8 +57,8 @@ return node; } - function controlId(publicId, fieldId) { - return "router-form-" + publicId + "-" + fieldId; + function controlId(publicId, instanceId, fieldId) { + return "router-form-" + publicId + "-" + instanceId + "-" + fieldId; } function appendHelp(container, field, id) { @@ -77,11 +78,11 @@ control.classList.add("router-form-v1__input"); } - function renderField(field, publicId) { + function renderField(field, publicId, instanceId) { var isGroup = ["radio", "checkbox-group", "yes-no"].indexOf(field.kind) !== -1; var wrapper = element(isGroup ? "fieldset" : "div", "router-form-v1__field"); wrapper.dataset.routerField = field.key; - var id = controlId(publicId, field.id); + var id = controlId(publicId, instanceId, field.id); var label = element(isGroup ? "legend" : "label", isGroup ? "router-form-v1__legend" : "router-form-v1__label", field.label); if (!isGroup) label.htmlFor = id; if (field.required) label.appendChild(element("span", "router-form-v1__required", " (required)")); @@ -127,13 +128,30 @@ input.name = field.key; input.value = option.value; input.id = id + "-" + index; - if (field.required && index === 0) input.required = true; + if (field.required && field.kind !== "checkbox-group" && index === 0) input.required = true; var defaults = Array.isArray(field.defaultValue) ? field.defaultValue : [String(field.defaultValue)]; input.checked = defaults.indexOf(option.value) !== -1; choiceLabel.appendChild(input); choiceLabel.appendChild(document.createTextNode(option.label)); choices.appendChild(choiceLabel); }); + if (field.kind === "checkbox-group") { + var checkboxInputs = choices.querySelectorAll('input[type="checkbox"]'); + var validationAnchor = checkboxInputs[0]; + var minimumSelections = Math.max(field.required ? 1 : 0, field.validation && field.validation.minSelections || 0); + var maximumSelections = field.validation && field.validation.maxSelections; + var syncCheckboxGroupValidity = function () { + var checked = Array.prototype.filter.call(checkboxInputs, function (input) { return input.checked; }).length; + var message = checked < minimumSelections + ? "Choose at least " + minimumSelections + " option" + (minimumSelections === 1 ? "." : "s.") + : maximumSelections !== undefined && checked > maximumSelections + ? "Choose no more than " + maximumSelections + " option" + (maximumSelections === 1 ? "." : "s.") + : ""; + if (validationAnchor) validationAnchor.setCustomValidity(message); + }; + checkboxInputs.forEach(function (input) { input.addEventListener("change", syncCheckboxGroupValidity); }); + syncCheckboxGroupValidity(); + } wrapper.appendChild(choices); } else if (field.kind === "checkbox" || field.kind === "switch") { label.remove(); @@ -213,7 +231,7 @@ if (!wrapper) return; var control = wrapper.querySelector("input,select,textarea"); var error = element("p", "router-form-v1__error", errors[key].join(" ")); - error.id = controlId(form.dataset.publicId, key) + "-error"; + error.id = controlId(form.dataset.publicId, form.dataset.instanceId, key) + "-error"; error.setAttribute("role", "alert"); wrapper.appendChild(error); if (control) { @@ -229,6 +247,7 @@ installStyles(); var definition = payload.definition || payload; var publicId = payload.publicId || options.publicId || "preview"; + var instanceId = String(++mountSequence); target.replaceChildren(); var root = element("section", "router-form-v1"); var header = element("header", "router-form-v1__header"); @@ -237,9 +256,10 @@ root.appendChild(header); var form = element("form", "router-form-v1__form"); form.dataset.publicId = publicId; + form.dataset.instanceId = instanceId; form.noValidate = false; var fields = element("div", "router-form-v1__fields"); - definition.fields.forEach(function (field) { fields.appendChild(renderField(field, publicId)); }); + definition.fields.forEach(function (field) { fields.appendChild(renderField(field, publicId, instanceId)); }); form.appendChild(fields); var honeypot = element("div", "router-form-v1__honeypot"); honeypot.setAttribute("aria-hidden", "true"); diff --git a/vercel.json b/vercel.json index 2cff490..8ab544a 100644 --- a/vercel.json +++ b/vercel.json @@ -3,6 +3,10 @@ { "path": "/api/cron", "schedule": "1 0 1 * *" + }, + { + "path": "/api/cron/forms-maintenance", + "schedule": "17 * * * *" } ] } From cec0d0a7e353ffd7ed7f7bff82e3d481fc2f1fd9 Mon Sep 17 00:00:00 2001 From: Bridger Tower Date: Tue, 1 Sep 2026 20:06:11 -0600 Subject: [PATCH 03/17] fix: track Next.js type declarations --- .gitignore | 1 - next-env.d.ts | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 next-env.d.ts diff --git a/.gitignore b/.gitignore index 8574637..1ae8417 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,6 @@ yarn-error.log* # typescript *.tsbuildinfo -next-env.d.ts .vscode .zshrc diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From eac46f1c2fa14e4d6a05460381aba9c9cae39e6e Mon Sep 17 00:00:00 2001 From: Bridger Tower Date: Tue, 1 Sep 2026 22:41:29 -0600 Subject: [PATCH 04/17] fix: close Router Forms review gaps --- .github/workflows/ci.yml | 44 +- .gitignore | 2 + .wp-env.6.6.json | 8 + .wp-env.latest.json | 8 + __tests__/entitlements.test.ts | 14 + __tests__/forms-definition.test.ts | 62 + __tests__/forms-security.test.ts | 15 + __tests__/stripe-subscription-state.test.ts | 7 + .../public/forms/[publicId]/leads/route.ts | 24 +- app/api/webhooks/stripe/route.ts | 61 +- app/page.tsx | 7 +- components/groups/forms/form-editor.tsx | 225 +- components/parts/usage.tsx | 2 +- docs/forms/release-runbook.md | 5 +- e2e/forms-runtime.spec.ts | 241 ++ integrations/wordpress/test-matrix.sh | 34 + lib/data/stripe.ts | 45 +- lib/data/users.ts | 2 + lib/db/drizzle/0013_tiny_giant_girl.sql | 5 + lib/db/drizzle/meta/0013_snapshot.json | 1293 +++++++++ lib/db/drizzle/meta/_journal.json | 7 + lib/db/schema.ts | 4 + lib/forms/definition.ts | 14 +- lib/forms/endpoint-schema.ts | 13 +- lib/forms/entitlements.ts | 32 +- lib/forms/field-identity.ts | 19 + lib/forms/lead-acceptance.ts | 23 +- lib/forms/starters.ts | 15 +- lib/forms/stripe-subscription-state.ts | 7 + package.json | 4 + playwright.config.ts | 19 + pnpm-lock.yaml | 2346 ++++++++++++++++- scripts/test-forward-migrations.sh | 24 + vitest.config.ts | 1 + 34 files changed, 4555 insertions(+), 77 deletions(-) create mode 100644 .wp-env.6.6.json create mode 100644 .wp-env.latest.json create mode 100644 e2e/forms-runtime.spec.ts create mode 100755 integrations/wordpress/test-matrix.sh create mode 100644 lib/db/drizzle/0013_tiny_giant_girl.sql create mode 100644 lib/db/drizzle/meta/0013_snapshot.json create mode 100644 lib/forms/field-identity.ts create mode 100644 playwright.config.ts create mode 100755 scripts/test-forward-migrations.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 365c61e..10826df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,13 +58,27 @@ jobs: env: PGURL: postgresql://postgres:postgres@localhost:5432/router_test run: | - for migration in lib/db/drizzle/*.sql; do - psql "$PGURL" -v ON_ERROR_STOP=1 -f "$migration" - done + chmod +x scripts/test-forward-migrations.sh + scripts/test-forward-migrations.sh - run: pnpm test:db env: TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/router_test + browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9.15.9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:browser + wordpress: runs-on: ubuntu-latest steps: @@ -77,3 +91,27 @@ jobs: php-version: "7.4" - run: chmod +x integrations/wordpress/check.sh integrations/wordpress/package.sh - run: integrations/wordpress/check.sh + + wordpress-runtime: + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + config: [".wp-env.6.6.json", ".wp-env.latest.json"] + theme: ["twentytwentyfour", "twentytwentyone"] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9.15.9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec wp-env start --config="${{ matrix.config }}" --update + - run: chmod +x integrations/wordpress/test-matrix.sh + - run: integrations/wordpress/test-matrix.sh "${{ matrix.config }}" "${{ matrix.theme }}" + - if: always() + run: pnpm exec wp-env stop --config="${{ matrix.config }}" diff --git a/.gitignore b/.gitignore index 1ae8417..544eed7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ # testing /coverage +/playwright-report/ +/test-results/ # next.js /.next/ diff --git a/.wp-env.6.6.json b/.wp-env.6.6.json new file mode 100644 index 0000000..1426f46 --- /dev/null +++ b/.wp-env.6.6.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://schemas.wp.org/trunk/wp-env.json", + "core": "WordPress/WordPress#6.6", + "phpVersion": "7.4", + "plugins": ["./integrations/wordpress/router-forms"], + "testsEnvironment": false, + "port": 8888 +} diff --git a/.wp-env.latest.json b/.wp-env.latest.json new file mode 100644 index 0000000..25c8c60 --- /dev/null +++ b/.wp-env.latest.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://schemas.wp.org/trunk/wp-env.json", + "core": null, + "phpVersion": "7.4", + "plugins": ["./integrations/wordpress/router-forms"], + "testsEnvironment": false, + "port": 8888 +} diff --git a/__tests__/entitlements.test.ts b/__tests__/entitlements.test.ts index ba11a88..88280f2 100644 --- a/__tests__/entitlements.test.ts +++ b/__tests__/entitlements.test.ts @@ -3,6 +3,7 @@ import { ENTITLEMENTS, getCapacityState, getEntitlement, + resolveMonthlyLeadLimit, } from "../lib/forms/entitlements"; describe("Forms entitlements", () => { @@ -37,4 +38,17 @@ describe("Forms entitlements", () => { expect(getCapacityState("free", 109)).toMatchObject({ state: "grace", accepts: true }); expect(getCapacityState("free", 110)).toMatchObject({ state: "paused", accepts: false }); }); + + it("requires an explicit Enterprise contract allowance", () => { + expect(resolveMonthlyLeadLimit("enterprise", {})).toBe(0); + expect( + resolveMonthlyLeadLimit("enterprise", { monthlyLeadLimit: 125_000 }) + ).toBe(125_000); + expect( + resolveMonthlyLeadLimit("enterprise", { unlimitedLeads: true }) + ).toBeNull(); + expect( + getCapacityState("enterprise", 137_500, { monthlyLeadLimit: 125_000 }) + ).toMatchObject({ state: "paused", accepts: false, limit: 125_000 }); + }); }); diff --git a/__tests__/forms-definition.test.ts b/__tests__/forms-definition.test.ts index 740c148..5f29705 100644 --- a/__tests__/forms-definition.test.ts +++ b/__tests__/forms-definition.test.ts @@ -10,6 +10,7 @@ import { isEndpointSchemaCompatible, seedDefinitionFromEndpoint, } from "../lib/forms/starters"; +import { allocateSubmissionKey } from "../lib/forms/field-identity"; const contactForm = { version: 1 as const, @@ -154,6 +155,67 @@ describe("FormDefinitionV1", () => { }, ]); }); + + it("preserves required checkbox semantics in the compiled endpoint schema", () => { + const definition = formDefinitionV1Schema.parse({ + ...contactForm, + fields: [ + { + id: "fld_consent", + key: "consent", + kind: "checkbox", + label: "I consent", + required: true, + }, + { + id: "fld_topics", + key: "topics", + kind: "checkbox-group", + label: "Topics", + required: true, + options: [{ id: "opt_sales", label: "Sales", value: "sales" }], + }, + ], + }); + const compiled = compileEndpointSchema(definition); + + expect(compiled).toEqual([ + { + key: "consent", + value: "boolean", + required: true, + constraints: { mustBeTrue: true }, + }, + { + key: "topics", + value: "string_array", + required: true, + constraints: { allowedValues: ["sales"], minItems: 1 }, + }, + ]); + expect( + validateEndpointValues(compiled, { consent: false, topics: [] }) + ).toMatchObject({ success: false }); + }); + + it("retains the nonnegative legacy number contract when reading and attaching", () => { + const legacyNumber = [{ key: "amount", value: "number" as const }]; + + expect(validateEndpointValues(legacyNumber, { amount: -1 })).toMatchObject({ + success: false, + }); + expect(seedDefinitionFromEndpoint("Payment", legacyNumber).fields[0]).toMatchObject({ + kind: "number", + validation: { min: 0 }, + }); + }); + + it("allocates a submission key that remains unique after field deletion", () => { + expect(allocateSubmissionKey("Text", ["text_1", "text_3"])).toBe("text_2"); + expect(allocateSubmissionKey("Text", ["text_1", "text_2", "text_3"])).toBe( + "text_4" + ); + }); }); describe("validateFormValues", () => { diff --git a/__tests__/forms-security.test.ts b/__tests__/forms-security.test.ts index 1e84cd9..beba4c8 100644 --- a/__tests__/forms-security.test.ts +++ b/__tests__/forms-security.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; import { createSubmissionToken, @@ -86,3 +87,17 @@ describe("published form cache validators", () => { ); }); }); + +describe("public form submission protection", () => { + it("counts honeypot submissions as rate-limited attempts", () => { + const source = readFileSync( + "app/api/public/forms/[publicId]/leads/route.ts", + "utf8" + ); + + expect(source.indexOf("await enforceFormRateLimit")).toBeGreaterThan(-1); + expect(source.indexOf("await enforceFormRateLimit")).toBeLessThan( + source.indexOf("if (parsed.website)") + ); + }); +}); diff --git a/__tests__/stripe-subscription-state.test.ts b/__tests__/stripe-subscription-state.test.ts index 4c13472..5347724 100644 --- a/__tests__/stripe-subscription-state.test.ts +++ b/__tests__/stripe-subscription-state.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { endedSubscriptionState, failedPaymentState, + shouldApplySubscriptionEvent, subscriptionEntitlementState, } from "../lib/forms/stripe-subscription-state"; @@ -64,4 +65,10 @@ describe("Stripe entitlement transitions", () => { "Unrecognized Stripe price" ); }); + + it("ignores events from a superseded subscription", () => { + expect(shouldApplySubscriptionEvent(null, "sub_legacy")).toBe(true); + expect(shouldApplySubscriptionEvent("sub_current", "sub_current")).toBe(true); + expect(shouldApplySubscriptionEvent("sub_current", "sub_legacy")).toBe(false); + }); }); diff --git a/app/api/public/forms/[publicId]/leads/route.ts b/app/api/public/forms/[publicId]/leads/route.ts index 16d6295..f6555e7 100644 --- a/app/api/public/forms/[publicId]/leads/route.ts +++ b/app/api/public/forms/[publicId]/leads/route.ts @@ -82,21 +82,19 @@ export async function POST( if (!form) return NextResponse.json({ error: "form_not_found" }, { status: 404 }); const corsHeaders = publicCorsHeaders(origin, Boolean(origin)); - // Honeypot submissions receive a neutral success but never create a lead. - // Validate the signed session and origin first so cross-origin clients still - // receive the same CORS boundary as a real submission. - if (parsed.website) { - return NextResponse.json( - { - leadId: "accepted", - completion: { type: "message", message: "Thanks." }, - }, - { headers: corsHeaders } - ); - } - try { await enforceFormRateLimit({ formId: form.id, ip: clientIp(request) }); + // Honeypot submissions count toward abuse limits, then receive a neutral + // success without creating a lead. + if (parsed.website) { + return NextResponse.json( + { + leadId: "accepted", + completion: { type: "message", message: "Thanks." }, + }, + { headers: corsHeaders } + ); + } const result = await acceptLead({ publicId, values: parsed.values, diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 954783b..7a85659 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -1,7 +1,7 @@ import { headers } from "next/headers"; import { NextResponse } from "next/server"; import type Stripe from "stripe"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull, or } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { db } from "@/lib/db"; import { users } from "@/lib/db/schema"; @@ -14,10 +14,41 @@ import { invalidatePublishedForm } from "@/lib/forms/cache"; import { endedSubscriptionState, failedPaymentState, + shouldApplySubscriptionEvent, subscriptionEntitlementState, } from "@/lib/forms/stripe-subscription-state"; +async function subscriptionOwner(subscription: Stripe.Subscription) { + const userCondition = subscription.metadata.routerUserId + ? eq(users.id, subscription.metadata.routerUserId) + : eq(users.stripeCustomerId, subscription.customer as string); + const [owner] = await db + .select({ id: users.id, stripeSubscriptionId: users.stripeSubscriptionId }) + .from(users) + .where(userCondition) + .limit(1); + if ( + !owner || + !shouldApplySubscriptionEvent(owner.stripeSubscriptionId, subscription.id) + ) { + return null; + } + return owner; +} + +function currentSubscriptionCondition(userId: string, subscriptionId: string) { + return and( + eq(users.id, userId), + or( + isNull(users.stripeSubscriptionId), + eq(users.stripeSubscriptionId, subscriptionId) + ) + ); +} + async function updateSubscription(subscription: Stripe.Subscription) { + const owner = await subscriptionOwner(subscription); + if (!owner) return; const priceId = subscription.items.data[0]?.price.id; if (!priceId) throw new Error("Subscription has no price."); const state = subscriptionEntitlementState({ @@ -29,13 +60,10 @@ async function updateSubscription(subscription: Stripe.Subscription) { cancelAtPeriodEnd: subscription.cancel_at_period_end, }); - const userCondition = subscription.metadata.routerUserId - ? eq(users.id, subscription.metadata.routerUserId) - : eq(users.stripeCustomerId, subscription.customer as string); const [updated] = await db .update(users) .set(state) - .where(userCondition) + .where(currentSubscriptionCondition(owner.id, subscription.id)) .returning({ id: users.id }); if (updated) { @@ -67,7 +95,12 @@ export async function POST(request: Request) { const priceId = lineItems.data[0]?.price?.id; const plan = priceId ? planForNewPrice(priceId) : null; if (!plan) throw new Error("Checkout used an unrecognized or retired price."); - if (!session.customer_details?.email) throw new Error("Checkout has no customer email."); + if (!session.metadata?.routerUserId && !session.customer_details?.email) { + throw new Error("Checkout has no Router user or customer email."); + } + const userCondition = session.metadata?.routerUserId + ? eq(users.id, session.metadata.routerUserId) + : eq(users.email, session.customer_details!.email!); const [updated] = await db .update(users) .set({ @@ -76,7 +109,15 @@ export async function POST(request: Request) { stripeSubscriptionId: session.subscription as string, legacyPriceMigrationRequired: false, }) - .where(eq(users.email, session.customer_details.email)) + .where( + and( + userCondition, + or( + isNull(users.stripeSubscriptionId), + eq(users.stripeSubscriptionId, session.subscription as string) + ) + ) + ) .returning({ id: users.id }); if (updated) { (await getUserPublishedFormIds(updated.id)).forEach(invalidatePublishedForm); @@ -92,10 +133,14 @@ export async function POST(request: Request) { if (event.type === "customer.subscription.deleted") { const subscription = event.data.object; + const owner = await subscriptionOwner(subscription); + if (!owner) { + return NextResponse.json({ success: true, ignored: "superseded_subscription" }); + } const [updated] = await db .update(users) .set(endedSubscriptionState(subscription.status)) - .where(eq(users.stripeCustomerId, subscription.customer as string)) + .where(currentSubscriptionCondition(owner.id, subscription.id)) .returning({ id: users.id }); if (updated) { (await getUserPublishedFormIds(updated.id)).forEach(invalidatePublishedForm); diff --git a/app/page.tsx b/app/page.tsx index 0d46000..9929aa6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,7 +12,7 @@ import { DataTable } from "@/components/groups/leads/data-table"; import { columns } from "@/components/groups/leads/columns"; import { getUsageForUser } from "@/lib/data/users"; import { Usage } from "@/components/parts/usage"; -import { getEntitlement } from "@/lib/forms/entitlements"; +import { resolveMonthlyLeadLimit } from "@/lib/forms/entitlements"; const pageData = { name: "Dashboard", @@ -57,7 +57,10 @@ export default async function Page() { const recentLeads = leadsData.slice(0, 5); // get the lead limit for the user's plan - const leadLimit = getEntitlement(usageData.plan).monthlyLeads; + const leadLimit = resolveMonthlyLeadLimit(usageData.plan, { + monthlyLeadLimit: usageData.enterpriseMonthlyLeadLimit, + unlimitedLeads: usageData.enterpriseUnlimitedLeads, + }); return ( <> diff --git a/components/groups/forms/form-editor.tsx b/components/groups/forms/form-editor.tsx index 1083ef8..04b3e20 100644 --- a/components/groups/forms/form-editor.tsx +++ b/components/groups/forms/form-editor.tsx @@ -47,6 +47,10 @@ import { SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; +import { + allocateSubmissionKey, + normalizeSubmissionKey, +} from "@/lib/forms/field-identity"; declare global { interface Window { @@ -93,20 +97,11 @@ const fieldKinds: Array<{ kind: FieldKind; label: string }> = [ { kind: "slider", label: "Slider" }, ]; -function stableKey(value: string): string { - const key = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, ""); - return /^[a-z]/.test(key) ? key : `field_${key || "value"}`; -} - -function makeField(kind: FieldKind, count: number): FormFieldV1 { +function makeField(kind: FieldKind, existingKeys: Iterable): FormFieldV1 { const label = fieldKinds.find((field) => field.kind === kind)?.label ?? "Field"; const base = { id: `fld_${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`, - key: `${stableKey(label)}_${count + 1}`, + key: allocateSubmissionKey(label, existingKeys), label, required: false, }; @@ -128,7 +123,7 @@ function makeField(kind: FieldKind, count: number): FormFieldV1 { } function changeFieldKind(field: FormFieldV1, kind: FieldKind): FormFieldV1 { - const replacement = makeField(kind, 0) as FormFieldV1 & Record; + const replacement = makeField(kind, []) as FormFieldV1 & Record; return { ...replacement, id: field.id, @@ -241,7 +236,7 @@ export function FormEditor({ form, origins: initialOrigins }: { form: EditorForm } function addField(kind: FieldKind) { - const field = makeField(kind, definition.fields.length); + const field = makeField(kind, definition.fields.map((item) => item.key)); setDefinition((current) => ({ ...current, fields: [...current.fields, field] })); setSelectedId(field.id); } @@ -337,7 +332,7 @@ export function FormEditor({ form, origins: initialOrigins }: { form: EditorForm ); const anchor = document.createElement("a"); anchor.href = url; - anchor.download = `${stableKey(name)}.router-form.json`; + anchor.download = `${normalizeSubmissionKey(name)}.router-form.json`; anchor.click(); URL.revokeObjectURL(url); } @@ -586,7 +581,7 @@ function FieldSettings({
- update({ key: stableKey(event.target.value) })} /> + update({ key: normalizeSubmissionKey(event.target.value) })} />
@@ -601,6 +596,83 @@ function FieldSettings({ + {(field.kind === "text" || + field.kind === "email" || + field.kind === "phone" || + field.kind === "url" || + field.kind === "textarea") && ( +
+ + {field.kind === "textarea" ? ( +