diff --git a/.env.example b/.env.example index 20004be..d705b7c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ 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 +CRON_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 +19,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..0ee663e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,119 @@ +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 + - run: pnpm check:server-actions + + 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: | + 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: + - 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 + + 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 playwright install --with-deps chromium + - 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 9dbdb7f..544eed7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ # testing /coverage +/playwright-report/ +/test-results/ # next.js /.next/ @@ -34,7 +36,9 @@ yarn-error.log* # typescript *.tsbuildinfo -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/.wp-env.6.6.json b/.wp-env.6.6.json new file mode 100644 index 0000000..d653d14 --- /dev/null +++ b/.wp-env.6.6.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://schemas.wp.org/trunk/wp-env.json", + "core": "WordPress/WordPress#6.6", + "phpVersion": "7.4", + "plugins": [ + "./integrations/wordpress/router-forms", + "./integrations/wordpress/test-fixtures/router-forms-test-api" + ], + "testsEnvironment": false, + "port": 8888 +} diff --git a/.wp-env.latest.json b/.wp-env.latest.json new file mode 100644 index 0000000..d760e01 --- /dev/null +++ b/.wp-env.latest.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://schemas.wp.org/trunk/wp-env.json", + "core": null, + "phpVersion": "7.4", + "plugins": [ + "./integrations/wordpress/router-forms", + "./integrations/wordpress/test-fixtures/router-forms-test-api" + ], + "testsEnvironment": false, + "port": 8888 +} 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__/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 new file mode 100644 index 0000000..4bc3990 --- /dev/null +++ b/__tests__/embed-runtime.test.ts @@ -0,0 +1,390 @@ +import { afterEach, beforeEach, describe, expect, it, vi } 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 } }, + ], +}; + +type Runtime = { + mount: (target: Element, options?: object) => Promise; +}; + +async function mountLiveForm(liveDefinition: FormDefinitionV1) { + const submissions: Array> = []; + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + const requestUrl = String(url); + if (requestUrl.endsWith("/render-session")) { + return new Response( + JSON.stringify({ submitToken: "token", revision: 1, expiresIn: 3600 }), + { status: 200 } + ); + } + if (requestUrl.endsWith("/leads")) { + submissions.push(JSON.parse(String(init?.body)) as Record); + return new Response( + JSON.stringify({ + leadId: "lead_1", + completion: liveDefinition.completion, + }), + { status: 200 } + ); + } + return new Response( + JSON.stringify({ + publicId: "live-form", + revision: 1, + definition: liveDefinition, + attribution: { visible: false }, + }), + { status: 200 } + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const target = document.createElement("div"); + target.setAttribute("data-router-form", "live-form"); + document.body.appendChild(target); + const runtime = (window as unknown as { RouterFormsV1: Runtime }).RouterFormsV1; + await runtime.mount(target); + return { target, submissions }; +} + +describe("embed v1 runtime", () => { + beforeEach(() => { + document.head.innerHTML = ""; + document.body.innerHTML = ""; + delete (window as unknown as { RouterFormsV1?: unknown }).RouterFormsV1; + window.eval(runtimeSource); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + 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("keeps a legitimate website field separate from the honeypot", async () => { + const { target, submissions } = await mountLiveForm({ + ...definition, + fields: [ + { + id: "website", + key: "website", + kind: "url", + label: "Website", + required: true, + }, + ], + }); + const website = target.querySelector( + '[data-router-field="website"] input' + )!; + website.value = "https://example.com"; + + target.querySelector("form")!.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }) + ); + await vi.waitFor(() => expect(submissions).toHaveLength(1)); + + expect(submissions[0]).toMatchObject({ + values: { website: "https://example.com" }, + website: "", + }); + }); + + it("omits an untouched optional slider without a default", async () => { + const { target, submissions } = await mountLiveForm({ + ...definition, + fields: [ + { + id: "score", + key: "score", + kind: "slider", + label: "Score", + required: false, + validation: { min: 1, max: 10, step: 1 }, + }, + ], + }); + + target.querySelector("form")!.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }) + ); + await vi.waitFor(() => expect(submissions).toHaveLength(1)); + + expect(submissions[0].values).toEqual({}); + }); + + it("announces and focuses an inline completion message", async () => { + const { target } = await mountLiveForm({ + ...definition, + fields: [], + completion: { type: "message", message: "Submission received." }, + }); + + target.querySelector("form")!.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }) + ); + await vi.waitFor(() => + expect(target.querySelector('[role="status"]')).not.toBeNull() + ); + + const status = target.querySelector('[role="status"]')!; + expect(status.textContent).toBe("Submission received."); + expect(status.getAttribute("aria-live")).toBe("polite"); + expect(document.activeElement).toBe(status); + }); + + it("refreshes a cached definition that does not match the render session", async () => { + const target = document.createElement("div"); + const currentDefinition = { ...definition, title: "Current revision" }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + publicId: "stale-form", + revision: 1, + definition: { ...definition, title: "Cached revision" }, + attribution: { visible: false }, + }), + { status: 200 } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + submitToken: "revision-two-token", + revision: 2, + expiresIn: 3600, + }), + { status: 200 } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + publicId: "stale-form", + revision: 2, + definition: currentDefinition, + attribution: { visible: false }, + }), + { status: 200 } + ) + ); + vi.stubGlobal("fetch", fetchMock); + target.setAttribute("data-router-form", "stale-form"); + const runtime = (window as unknown as { + RouterFormsV1: { mount: (target: Element) => Promise }; + }).RouterFormsV1; + + await runtime.mount(target); + + expect(target.querySelector("h2")?.textContent).toBe("Current revision"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[2][0]).toContain("?revision=2"); + expect(fetchMock.mock.calls[2][1]).toMatchObject({ cache: "no-store" }); + }); + + it("installs runtime styles in an iframe preview document", async () => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const iframeDocument = iframe.contentDocument!; + const target = iframeDocument.createElement("div"); + iframeDocument.body.appendChild(target); + const runtime = (window as unknown as { + RouterFormsV1: { mount: (target: Element, options: object) => Promise }; + }).RouterFormsV1; + + await runtime.mount(target, { + definition, + publicId: "iframe-preview", + preview: true, + }); + + expect(target.querySelector("form")).not.toBeNull(); + expect(iframeDocument.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.each(["radio", "checkbox-group"] as const)( + "does not select an %s option when its value is the string undefined", + async (kind) => { + const target = document.createElement("div"); + document.body.appendChild(target); + const runtime = (window as unknown as { + RouterFormsV1: { + mount: (target: Element, options: object) => Promise; + }; + }).RouterFormsV1; + const choiceDefinition: FormDefinitionV1 = { + ...definition, + fields: [ + { + id: "choice", + key: "choice", + kind, + label: "Choice", + required: true, + options: [ + { + id: "undefined_option", + label: "No default", + value: "undefined", + }, + ], + }, + ], + }; + + await runtime.mount(target, { + definition: choiceDefinition, + publicId: `undefined-${kind}`, + preview: true, + }); + + const input = target.querySelector("input")!; + expect(input.checked).toBe(false); + expect(target.querySelector("form")!.checkValidity()).toBe(false); + } + ); + + 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) => { + 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..88280f2 --- /dev/null +++ b/__tests__/entitlements.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + ENTITLEMENTS, + getCapacityState, + getEntitlement, + resolveMonthlyLeadLimit, +} 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 }); + }); + + 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-db.integration.test.ts b/__tests__/forms-db.integration.test.ts new file mode 100644 index 0000000..caf6f3f --- /dev/null +++ b/__tests__/forms-db.integration.test.ts @@ -0,0 +1,393 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { Pool } from "pg"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { eq } from "drizzle-orm"; +import { + endpoints, + forms, + leads, + usagePeriods, + users, +} from "../lib/db/schema"; +import { FORM_STARTERS } from "../lib/forms/starters"; +import { + FormDraftConflictError, + FormPublicationConflictError, + publishFormForUser, + saveFormDraftForUser, +} from "../lib/forms/publication"; +import { + AttachedFormExistsError, + deleteEndpointForUser, + deleteFormForUser, +} from "../lib/forms/lifecycle"; +import { + acceptLead, + LeadCapacityError, + LeadStaleRevisionError, +} from "../lib/forms/lead-acceptance"; + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +const databaseUrl = process.env.TEST_DATABASE_URL; +const suite = databaseUrl ? describe.sequential : describe.skip; + +suite("Forms PostgreSQL integration through production services", () => { + const pool = new Pool({ connectionString: databaseUrl }); + const database = drizzle(pool); + const serviceDatabase = database as unknown as Parameters< + typeof publishFormForUser + >[1]; + const userIds: string[] = []; + const userId = `test-${randomUUID()}`; + let endpointId = ""; + let formId = ""; + + beforeAll(async () => { + userIds.push(userId); + 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; + }); + + it("indexes form-filtered leads by creation time", async () => { + const result = await pool.query<{ indexdef: string }>(` + SELECT indexdef + FROM pg_indexes + WHERE schemaname = 'public' AND indexname = 'lead_form_created_idx' + `); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0].indexdef).toContain('(\"formId\", \"createdAt\")'); + }); + + afterAll(async () => { + for (const id of userIds) { + await database.delete(users).where(eq(users.id, id)); + } + await pool.end(); + }); + + it("rejects one of two draft saves that start from the same revision", async () => { + const attempts = await Promise.allSettled([ + saveFormDraftForUser({ + id: formId, + userId, + expectedRevision: 1, + name: "Integration form A", + definition: { ...FORM_STARTERS.contact, title: "Contact A" }, + }, serviceDatabase), + saveFormDraftForUser({ + id: formId, + userId, + expectedRevision: 1, + name: "Integration form B", + definition: { ...FORM_STARTERS.contact, title: "Contact B" }, + }, serviceDatabase), + ]); + + expect(attempts.filter((attempt) => attempt.status === "fulfilled")).toHaveLength(1); + const rejection = attempts.find((attempt) => attempt.status === "rejected"); + expect(rejection).toMatchObject({ reason: expect.any(FormDraftConflictError) }); + }); + + it("publishes the endpoint schema and immutable snapshot through the production transaction", async () => { + const published = await publishFormForUser({ + id: formId, + userId, + expectedDraftRevision: 2, + }, serviceDatabase); + + const [storedForm] = 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(storedForm.publishedRevision).toBe(1); + expect(storedForm.publishedDefinition).toEqual(storedForm.draftDefinition); + expect(endpoint.schema).toMatchObject([ + { key: "name", value: "string", required: true }, + { key: "email", value: "email", required: true }, + { key: "message", value: "string", required: true }, + ]); + }); + + it("allows only one publisher to claim the same draft and public revision", async () => { + const attempts = await Promise.allSettled([ + publishFormForUser({ id: formId, userId, expectedDraftRevision: 2 }, serviceDatabase), + publishFormForUser({ id: formId, userId, expectedDraftRevision: 2 }, serviceDatabase), + ]); + + expect(attempts.filter((attempt) => attempt.status === "fulfilled")).toHaveLength(1); + const rejection = attempts.find((attempt) => attempt.status === "rejected"); + expect(rejection).toMatchObject({ + reason: expect.any(FormPublicationConflictError), + }); + }); + + it("blocks endpoint deletion through the production lifecycle service", async () => { + await expect( + deleteEndpointForUser({ id: endpointId, userId }, serviceDatabase) + ).rejects.toBeInstanceOf(AttachedFormExistsError); + }); + + it("removing a form preserves its endpoint and attributed leads", async () => { + const lifecycleUserId = `test-${randomUUID()}`; + userIds.push(lifecycleUserId); + await database.insert(users).values({ + id: lifecycleUserId, + email: `${lifecycleUserId}@example.com`, + }); + const [endpoint] = await database + .insert(endpoints) + .values({ + userId: lifecycleUserId, + name: "Attached endpoint", + schema: [{ key: "email", value: "email", required: true }], + token: "attached-token", + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ id: endpoints.id }); + const [form] = await database + .insert(forms) + .values({ + userId: lifecycleUserId, + endpointId: endpoint.id, + name: "Attached form", + draftDefinition: FORM_STARTERS.newsletter, + publishedDefinition: FORM_STARTERS.newsletter, + publishedRevision: 1, + publishedAt: new Date(), + attachedToExistingEndpoint: true, + }) + .returning({ id: forms.id }); + const [lead] = await database + .insert(leads) + .values({ + endpointId: endpoint.id, + formId: form.id, + formRevision: 1, + placement: "wordpress", + data: { email: "lead@example.com", consent: true }, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ id: leads.id }); + + await deleteFormForUser( + { id: form.id, userId: lifecycleUserId }, + serviceDatabase + ); + + expect( + await database + .select({ id: endpoints.id }) + .from(endpoints) + .where(eq(endpoints.id, endpoint.id)) + ).toHaveLength(1); + expect( + await database + .select({ id: leads.id, formId: leads.formId }) + .from(leads) + .where(eq(leads.id, lead.id)) + ).toEqual([{ id: lead.id, formId: null }]); + }); + + it("rejects a render session whose published revision changed before acceptance", async () => { + const [storedForm] = await database + .select({ publicId: forms.publicId }) + .from(forms) + .where(eq(forms.id, formId)); + + await expect( + acceptLead({ + publicId: storedForm.publicId, + publishedRevision: 1, + placement: "hosted", + values: { + name: "Ada Lovelace", + email: "ada@example.com", + message: "Revision safety", + }, + }, new Date(), serviceDatabase) + ).rejects.toBeInstanceOf(LeadStaleRevisionError); + + const attributed = await database + .select({ id: leads.id }) + .from(leads) + .where(eq(leads.formId, formId)); + expect(attributed).toHaveLength(0); + }); + + it("enforces grace capacity atomically under concurrent production acceptance", async () => { + const quotaUserId = `test-${randomUUID()}`; + userIds.push(quotaUserId); + await database.insert(users).values({ + id: quotaUserId, + email: `${quotaUserId}@example.com`, + plan: "enterprise", + enterpriseMonthlyLeadLimit: 10, + }); + const [endpoint] = await database + .insert(endpoints) + .values({ + userId: quotaUserId, + name: "Quota endpoint", + schema: [{ key: "email", value: "email", required: true }], + token: "quota-token", + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ id: endpoints.id }); + + const attempts = await Promise.allSettled( + Array.from({ length: 20 }, (_, index) => + acceptLead({ + endpointId: endpoint.id, + placement: "headless", + values: { email: `lead-${index}@example.com` }, + }, new Date(), serviceDatabase) + ) + ); + + expect(attempts.filter((attempt) => attempt.status === "fulfilled")).toHaveLength(11); + expect( + attempts + .filter((attempt) => attempt.status === "rejected") + .every((attempt) => attempt.reason instanceof LeadCapacityError) + ).toBe(true); + const [usage] = await database + .select({ leadCount: usagePeriods.leadCount }) + .from(usagePeriods) + .where(eq(usagePeriods.userId, quotaUserId)); + expect(usage.leadCount).toBe(11); + }); + + it("preserves legacy endpoint validation and webhook delivery", async () => { + const [endpoint] = await database + .insert(endpoints) + .values({ + userId, + name: "Legacy webhook endpoint", + schema: [ + { key: "name", value: "string" }, + { key: "score", value: "number" }, + ], + webhookEnabled: true, + webhook: "https://hooks.example.com/router", + token: "legacy-token", + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ id: endpoints.id }); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 204 })); + + const accepted = await acceptLead({ + endpointId: endpoint.id, + placement: "headless", + values: { name: "Grace Hopper", score: 4 }, + }, new Date(), serviceDatabase); + + expect(accepted.leadId).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledWith( + "https://hooks.example.com/router", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ name: "Grace Hopper", score: 4 }), + }) + ); + fetchMock.mockRestore(); + }); + + it("keeps an accepted lead successful when post-commit webhook logging fails", async () => { + const [endpoint] = await database + .insert(endpoints) + .values({ + userId, + name: "Webhook logging failure endpoint", + schema: [{ key: "email", value: "email", required: true }], + webhookEnabled: true, + webhook: "https://hooks.example.com/router", + token: "webhook-log-failure-token", + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ id: endpoints.id }); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 204 })); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + await pool.query(` + CREATE FUNCTION reject_webhook_logs() RETURNS trigger AS $$ + BEGIN + IF NEW."postType" = 'webhook' THEN + RAISE EXCEPTION 'webhook log storage unavailable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER reject_webhook_logs + BEFORE INSERT ON "log" + FOR EACH ROW EXECUTE FUNCTION reject_webhook_logs(); + `); + + try { + const accepted = await acceptLead( + { + endpointId: endpoint.id, + placement: "headless", + values: { email: "accepted@example.com" }, + }, + new Date(), + serviceDatabase + ); + + expect(accepted.leadId).toBeTruthy(); + expect( + await database + .select({ id: leads.id }) + .from(leads) + .where(eq(leads.id, accepted.leadId)) + ).toHaveLength(1); + } finally { + await pool.query('DROP TRIGGER reject_webhook_logs ON "log"'); + await pool.query("DROP FUNCTION reject_webhook_logs()"); + fetchMock.mockRestore(); + consoleError.mockRestore(); + } + }); +}); diff --git a/__tests__/forms-definition.test.ts b/__tests__/forms-definition.test.ts new file mode 100644 index 0000000..0e9e67a --- /dev/null +++ b/__tests__/forms-definition.test.ts @@ -0,0 +1,509 @@ +import { describe, expect, it } from "vitest"; +import { + compileEndpointSchema, + formDraftDefinitionV1Schema, + formDefinitionV1Schema, + hasEndpointSchemaChanged, + validateFormValues, +} from "../lib/forms/definition"; +import { validateEndpointValues } from "../lib/forms/endpoint-schema"; +import { + isEndpointSchemaCompatible, + hasEndpointSchemaChangedFromEndpoint, + seedDefinitionFromEndpoint, +} from "../lib/forms/starters"; +import { allocateSubmissionKey } from "../lib/forms/field-identity"; + +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("rejects option bounds and defaults that cannot produce valid submissions", () => { + const impossibleGroup = formDefinitionV1Schema.safeParse({ + ...contactForm, + fields: [ + { + ...contactForm.fields[2], + validation: { minSelections: 3, maxSelections: 3 }, + defaultValue: ["sales", "removed"], + }, + ], + }); + const staleChoice = formDefinitionV1Schema.safeParse({ + ...contactForm, + fields: [ + { + id: "fld_topic", + key: "topic", + kind: "select", + label: "Topic", + required: false, + options: [{ id: "opt_sales", label: "Sales", value: "sales" }], + defaultValue: "removed", + }, + ], + }); + + expect(impossibleGroup.success).toBe(false); + expect(staleChoice.success).toBe(false); + }); + + it("rejects defaults outside authored field constraints", () => { + const result = formDefinitionV1Schema.safeParse({ + ...contactForm, + fields: [ + { + id: "fld_score", + key: "score", + kind: "slider", + label: "Score", + required: false, + defaultValue: 12, + validation: { min: 0, max: 10, step: 2 }, + }, + { + id: "fld_email", + key: "email", + kind: "email", + label: "Email", + required: false, + defaultValue: "not-an-email", + }, + ], + }); + + 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([ + { + 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, + }, + }, + ]); + }); + + it("distinguishes presentation edits from endpoint schema changes", () => { + expect( + hasEndpointSchemaChanged( + { + ...contactForm, + title: "Updated public title", + description: "Updated description", + submitLabel: "Continue", + completion: { type: "message", message: "Updated thanks" }, + }, + contactForm + ) + ).toBe(false); + + expect( + hasEndpointSchemaChanged( + { + ...contactForm, + fields: contactForm.fields.map((field) => + field.id === "fld_email" ? { ...field, required: false } : field + ), + }, + contactForm + ) + ).toBe(true); + }); + + 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" + ); + + const tooManyFields = Array.from({ length: 101 }, (_, index) => ({ + key: `field_${index}`, + value: "string" as const, + })); + const tooManyOptions = [ + { + key: "choice", + value: "string" as const, + constraints: { + allowedValues: Array.from({ length: 101 }, (_, index) => `option_${index}`), + }, + }, + ]; + expect(isEndpointSchemaCompatible(tooManyFields)).toBe(false); + expect(isEndpointSchemaCompatible(tooManyOptions)).toBe(false); + }); + + it("compares an attached form's first publication with the endpoint contract", () => { + const endpointSchema = [ + { key: "name", value: "string" as const }, + { key: "score", value: "number" as const }, + ]; + const seeded = seedDefinitionFromEndpoint("Qualification", endpointSchema); + + expect( + hasEndpointSchemaChangedFromEndpoint(seeded, endpointSchema) + ).toBe(false); + expect( + hasEndpointSchemaChangedFromEndpoint( + { + ...seeded, + fields: seeded.fields.map((field) => + field.key === "score" ? { ...field, required: false } : field + ), + }, + endpointSchema + ) + ).toBe(true); + }); + + 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 }, + }, + ]); + }); + + 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("uses the same must-be-on semantics for required switches everywhere", () => { + const definition = formDefinitionV1Schema.parse({ + ...contactForm, + fields: [ + { + id: "fld_updates", + key: "updates", + kind: "switch", + label: "Receive updates", + required: true, + }, + ], + }); + + expect(compileEndpointSchema(definition)).toEqual([ + { + key: "updates", + value: "boolean", + required: true, + constraints: { mustBeTrue: true }, + }, + ]); + expect(validateFormValues(definition, { updates: false })).toMatchObject({ + success: false, + }); + expect( + validateEndpointValues(compileEndpointSchema(definition), { + updates: false, + }) + ).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", () => { + 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."], + }); + } + }); + + it("rejects an empty string for a required numeric field", () => { + const definition = formDefinitionV1Schema.parse({ + ...contactForm, + fields: [ + { + id: "fld_count", + key: "count", + kind: "number", + label: "Count", + required: true, + validation: { min: 0 }, + }, + ], + }); + + expect(validateFormValues(definition, { count: "" })).toMatchObject({ + success: false, + errors: { count: ["This field is required."] }, + }); + }); + + 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-maintenance-route.test.ts b/__tests__/forms-maintenance-route.test.ts new file mode 100644 index 0000000..14c85e5 --- /dev/null +++ b/__tests__/forms-maintenance-route.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + pruneFormRateBuckets: vi.fn(), + retryPendingUsageNotifications: vi.fn(), +})); + +vi.mock("@/lib/forms/rate-limit", () => ({ + pruneFormRateBuckets: mocks.pruneFormRateBuckets, +})); + +vi.mock("@/lib/forms/usage-notifications", () => ({ + retryPendingUsageNotifications: mocks.retryPendingUsageNotifications, +})); + +import { GET } from "../app/api/cron/forms-maintenance/route"; + +describe("Forms maintenance route authentication", () => { + beforeEach(() => { + delete process.env.CRON_SECRET; + mocks.pruneFormRateBuckets.mockReset(); + mocks.retryPendingUsageNotifications.mockReset(); + }); + + afterEach(() => { + delete process.env.CRON_SECRET; + }); + + it("fails closed when CRON_SECRET is not configured", async () => { + const response = await GET( + new Request("https://app.router.so/api/cron/forms-maintenance", { + headers: { authorization: "Bearer undefined" }, + }) as never + ); + + expect(response.status).toBe(503); + expect(mocks.pruneFormRateBuckets).not.toHaveBeenCalled(); + expect(mocks.retryPendingUsageNotifications).not.toHaveBeenCalled(); + }); + + it("runs maintenance only with the configured bearer secret", async () => { + process.env.CRON_SECRET = "maintenance-secret"; + mocks.pruneFormRateBuckets.mockResolvedValue(4); + mocks.retryPendingUsageNotifications.mockResolvedValue({ + attempted: 2, + delivered: 1, + }); + + const response = await GET( + new Request("https://app.router.so/api/cron/forms-maintenance", { + headers: { authorization: "Bearer maintenance-secret" }, + }) as never + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + success: true, + prunedRateBuckets: 4, + usageNotifications: { attempted: 2, delivered: 1 }, + }); + }); +}); diff --git a/__tests__/forms-security.test.ts b/__tests__/forms-security.test.ts new file mode 100644 index 0000000..f552054 --- /dev/null +++ b/__tests__/forms-security.test.ts @@ -0,0 +1,263 @@ +import { createHmac } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { + createSubmissionToken, + submissionTokenMatchesRequest, + verifySubmissionToken, +} from "../lib/forms/submission-token"; +import { isHostedFormRequest, normalizeOrigin } from "../lib/forms/origins"; +import { publishedFormEtag } from "../lib/forms/cache"; + +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", + revision: 7, + placement: "embed", + origin: "https://example.com", + }, + { secret, now } + ); + + expect(verifySubmissionToken(token, { secret, now })).toMatchObject({ + publicId: "form_public_1", + revision: 7, + 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", + revision: 7, + placement: "hosted", + origin: "https://forms.router.so", + }, + { 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(); + }); + + it("requires every token to match the request form and origin", () => { + const token = verifySubmissionToken( + createSubmissionToken( + { + publicId: "form_public_1", + revision: 7, + placement: "hosted", + origin: "https://forms.router.so", + }, + { secret } + ), + { secret } + ); + + expect( + submissionTokenMatchesRequest(token, { + publicId: "form_public_1", + revision: 7, + origin: "https://forms.router.so", + }) + ).toBe(true); + expect( + submissionTokenMatchesRequest(token, { + publicId: "form_public_1", + revision: 7, + origin: "https://attacker.example", + }) + ).toBe(false); + expect( + submissionTokenMatchesRequest(token, { + publicId: "form_public_1", + revision: 7, + origin: null, + }) + ).toBe(false); + }); + + it("rejects legacy signed tokens without an origin claim", () => { + const now = new Date("2026-09-01T18:00:00.000Z"); + const token = createSubmissionToken( + { + publicId: "form_public_1", + revision: 7, + placement: "hosted", + origin: "https://forms.router.so", + }, + { secret, now } + ); + const [encodedPayload] = token.split("."); + const payload = JSON.parse( + Buffer.from(encodedPayload, "base64url").toString("utf8") + ); + delete payload.origin; + const originlessPayload = Buffer.from(JSON.stringify(payload), "utf8").toString( + "base64url" + ); + const originlessSignature = createHmac("sha256", secret) + .update(originlessPayload) + .digest("base64url"); + + expect(() => + verifySubmissionToken(`${originlessPayload}.${originlessSignature}`, { + secret, + now, + }) + ).toThrow("Invalid submission token"); + }); + + it("rejects legacy signed tokens without a published revision claim", () => { + const now = new Date("2026-09-01T18:00:00.000Z"); + const token = createSubmissionToken( + { + publicId: "form_public_1", + revision: 7, + placement: "hosted", + origin: "https://forms.router.so", + }, + { secret, now } + ); + const [encodedPayload] = token.split("."); + const payload = JSON.parse( + Buffer.from(encodedPayload, "base64url").toString("utf8") + ); + delete payload.revision; + const revisionlessPayload = Buffer.from( + JSON.stringify(payload), + "utf8" + ).toString("base64url"); + const revisionlessSignature = createHmac("sha256", secret) + .update(revisionlessPayload) + .digest("base64url"); + + expect(() => + verifySubmissionToken(`${revisionlessPayload}.${revisionlessSignature}`, { + secret, + now, + }) + ).toThrow("Invalid submission token"); + }); +}); + +describe("hosted form origin checks", () => { + it("accepts exact hosted origins and rejects missing, opaque, or foreign origins", () => { + const url = "https://forms.router.so/api/public/forms/form_1/render-session"; + + expect( + isHostedFormRequest( + new Request(url, { headers: { origin: "https://forms.router.so" } }) + ) + ).toBe(true); + expect(isHostedFormRequest(new Request(url))).toBe(false); + expect( + isHostedFormRequest(new Request(url, { headers: { origin: "null" } })) + ).toBe(false); + expect( + isHostedFormRequest( + new Request(url, { headers: { origin: "https://attacker.example" } }) + ) + ).toBe(false); + for (const malformedOrigin of [ + "https://forms.router.so/", + "https://forms.router.so/path", + "https://forms.router.so?query=1", + "https://forms.router.so#fragment", + "https://forms.router.so:443", + ]) { + expect( + isHostedFormRequest( + new Request(url, { headers: { origin: malformedOrigin } }) + ) + ).toBe(false); + } + }); + + it("keeps exact-origin local hosted development working", () => { + expect( + isHostedFormRequest( + new Request("http://localhost:3000/api/public/forms/form_1/render-session", { + headers: { origin: "http://localhost:3000" }, + }) + ) + ).toBe(true); + expect( + isHostedFormRequest( + new Request("http://[::1]:3000/api/public/forms/form_1/render-session", { + headers: { origin: "http://[::1]:3000" }, + }) + ) + ).toBe(true); + }); +}); + +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, + }) + ); + }); +}); + +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__/latest-save-queue.test.ts b/__tests__/latest-save-queue.test.ts new file mode 100644 index 0000000..16374da --- /dev/null +++ b/__tests__/latest-save-queue.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { createLatestSaveQueue } from "../lib/forms/latest-save-queue"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("createLatestSaveQueue", () => { + it("does not resolve until edits made during a save are persisted", async () => { + let current = { title: "First edit" }; + let persisted = JSON.stringify({ title: "Initial" }); + const firstSave = deferred(); + const savedTitles: string[] = []; + const queue = createLatestSaveQueue({ + getSnapshot: () => current, + getPersistedFingerprint: () => persisted, + fingerprint: JSON.stringify, + save: async (snapshot, fingerprint) => { + savedTitles.push(snapshot.title); + if (savedTitles.length === 1) await firstSave.promise; + persisted = fingerprint; + return true; + }, + }); + + const completion = queue.persist(); + current = { title: "Edit made while saving" }; + firstSave.resolve(true); + + await expect(completion).resolves.toBe(true); + expect(savedTitles).toEqual(["First edit", "Edit made while saving"]); + expect(persisted).toBe(JSON.stringify(current)); + }); + + it("shares an active save while still draining the latest snapshot", async () => { + let current = { title: "First edit" }; + let persisted = JSON.stringify({ title: "Initial" }); + const firstSave = deferred(); + const savedTitles: string[] = []; + const queue = createLatestSaveQueue({ + getSnapshot: () => current, + getPersistedFingerprint: () => persisted, + fingerprint: JSON.stringify, + save: async (snapshot, fingerprint) => { + savedTitles.push(snapshot.title); + if (savedTitles.length === 1) await firstSave.promise; + persisted = fingerprint; + return true; + }, + }); + + const firstCompletion = queue.persist(); + current = { title: "Queued edit" }; + const secondCompletion = queue.persist(); + firstSave.resolve(true); + + await expect(Promise.all([firstCompletion, secondCompletion])).resolves.toEqual([ + true, + true, + ]); + expect(savedTitles).toEqual(["First edit", "Queued edit"]); + }); +}); diff --git a/__tests__/next-config.test.ts b/__tests__/next-config.test.ts new file mode 100644 index 0000000..527994e --- /dev/null +++ b/__tests__/next-config.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +type HeaderRoute = { + source: string; + headers: Array<{ key: string; value: string }>; +}; + +describe("Next.js public asset configuration", () => { + it("serves the versioned embed runtime with an immutable cache policy", async () => { + const { default: nextConfig } = await import("../next.config.mjs"); + const config = nextConfig as { headers: () => Promise }; + const routes = await config.headers(); + const runtime = routes.find((route) => route.source === "/embed/v1.js"); + + expect(runtime?.headers).toContainEqual({ + key: "Cache-Control", + value: "public, max-age=31536000, immutable", + }); + }); +}); diff --git a/__tests__/plan-tiles.test.tsx b/__tests__/plan-tiles.test.tsx new file mode 100644 index 0000000..3cd1ed2 --- /dev/null +++ b/__tests__/plan-tiles.test.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/data/stripe", () => ({ + createCustomerPortalSession: vi.fn(), + postStripeSession: vi.fn(), +})); + +import { PlanTiles } from "../app/upgrade/plan-tiles"; + +describe("plan pricing", () => { + it("keeps Free distinct from custom pricing in annual mode", () => { + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Annual" })); + + const freeCard = screen.getByRole("heading", { name: "Free" }).closest("article"); + const enterpriseCard = screen + .getByRole("heading", { name: "Enterprise" }) + .closest("article"); + expect(freeCard?.textContent).toContain("$0"); + expect(freeCard?.textContent).not.toContain("Custom"); + expect(enterpriseCard?.textContent).toContain("Custom"); + }); +}); diff --git a/__tests__/public-forms-routes.test.ts b/__tests__/public-forms-routes.test.ts new file mode 100644 index 0000000..5e44370 --- /dev/null +++ b/__tests__/public-forms-routes.test.ts @@ -0,0 +1,270 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createSubmissionToken, + verifySubmissionToken, +} from "../lib/forms/submission-token"; + +const mocks = vi.hoisted(() => ({ + acceptLead: vi.fn(), + enforceFormRateLimit: vi.fn(), + getPublishedForm: vi.fn(), + isApprovedFormOrigin: vi.fn(), + publicFormOptionsResponse: vi.fn(), +})); + +vi.mock("@/lib/data/public-forms", () => ({ + getPublishedForm: mocks.getPublishedForm, +})); + +vi.mock("@/lib/forms/lead-acceptance", () => { + class LeadCapacityError extends Error {} + class LeadEndpointError extends Error { + status = 503; + } + class LeadStaleRevisionError extends Error { + currentRevision = 7; + } + class LeadValidationError extends Error { + fieldErrors = {}; + } + return { + acceptLead: mocks.acceptLead, + LeadCapacityError, + LeadEndpointError, + LeadStaleRevisionError, + LeadValidationError, + }; +}); + +vi.mock("@/lib/forms/public-access", () => ({ + isApprovedFormOrigin: mocks.isApprovedFormOrigin, + publicCorsHeaders: (origin: string | null, approved: boolean) => { + const headers = new Headers(); + if (origin && approved) headers.set("Access-Control-Allow-Origin", origin); + return headers; + }, + publicFormOptionsResponse: mocks.publicFormOptionsResponse, +})); + +vi.mock("@/lib/forms/rate-limit", () => { + class FormRateLimitError extends Error { + retryAfter = 60; + } + return { + enforceFormRateLimit: mocks.enforceFormRateLimit, + FormRateLimitError, + }; +}); + +vi.mock("@/lib/forms/feature-flags", () => ({ + publicFormsEnabled: () => true, +})); + +import { + OPTIONS as formOptions, +} from "../app/api/public/forms/[publicId]/route"; +import { + OPTIONS as renderSessionOptions, + POST as createRenderSession, +} from "../app/api/public/forms/[publicId]/render-session/route"; +import { + OPTIONS as leadOptions, + POST as submitLead, +} from "../app/api/public/forms/[publicId]/leads/route"; + +const secret = "route-test-secret-with-enough-entropy"; +const publicId = "form_public_1"; +const params = { params: Promise.resolve({ publicId }) }; + +function renderSessionRequest(origin?: string) { + return new Request( + `https://forms.router.so/api/public/forms/${publicId}/render-session`, + { + method: "POST", + headers: { + "content-type": "application/json", + ...(origin ? { origin } : {}), + }, + body: JSON.stringify({ placement: "hosted" }), + } + ); +} + +function leadRequest(input: { + token: string; + origin?: string; + contentType?: string; +}) { + return new Request( + `https://forms.router.so/api/public/forms/${publicId}/leads`, + { + method: "POST", + headers: { + "content-type": input.contentType ?? "application/json", + ...(input.origin ? { origin: input.origin } : {}), + }, + body: JSON.stringify({ values: {}, submitToken: input.token }), + } + ); +} + +describe("public form route origin enforcement", () => { + beforeEach(() => { + process.env.FORM_SUBMISSION_SECRET = secret; + mocks.acceptLead.mockReset(); + mocks.acceptLead.mockResolvedValue({ + leadId: "lead_1", + completion: { type: "message", message: "Thanks." }, + }); + mocks.enforceFormRateLimit.mockReset(); + mocks.getPublishedForm.mockReset(); + mocks.getPublishedForm.mockResolvedValue({ id: "form_1", revision: 7 }); + mocks.isApprovedFormOrigin.mockReset(); + mocks.isApprovedFormOrigin.mockResolvedValue(true); + mocks.publicFormOptionsResponse.mockReset(); + mocks.publicFormOptionsResponse.mockResolvedValue( + new Response(null, { + status: 204, + headers: { "Access-Control-Allow-Origin": "https://site.example" }, + }) + ); + }); + + it.each([formOptions, renderSessionOptions, leadOptions])( + "uses the shared preflight policy for every public form endpoint", + async (options) => { + const request = new Request( + `https://forms.router.so/api/public/forms/${publicId}`, + { method: "OPTIONS", headers: { origin: "https://site.example" } } + ); + + const response = await options(request, params); + + expect(response.status).toBe(204); + expect(mocks.publicFormOptionsResponse).toHaveBeenCalledWith( + request, + publicId + ); + } + ); + + it("mints an origin-bound token only for the exact hosted origin", async () => { + const response = await createRenderSession( + renderSessionRequest("https://forms.router.so"), + params + ); + const body = (await response.json()) as { + submitToken: string; + revision: number; + }; + + expect(response.status).toBe(200); + expect(body.revision).toBe(7); + expect(verifySubmissionToken(body.submitToken, { secret })).toMatchObject({ + publicId, + revision: 7, + placement: "hosted", + origin: "https://forms.router.so", + }); + }); + + it.each([ + undefined, + "null", + "https://attacker.example", + "https://forms.router.so/", + "https://forms.router.so/path", + "https://forms.router.so?query=1", + ])("rejects an invalid hosted Origin serialization: %s", async (origin) => { + const response = await createRenderSession(renderSessionRequest(origin), params); + + expect(response.status).toBe(403); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); + + it("rejects malformed-origin text/plain submissions before lead side effects", async () => { + const token = createSubmissionToken( + { + publicId, + revision: 7, + placement: "hosted", + origin: "https://forms.router.so", + }, + { secret } + ); + const response = await submitLead( + leadRequest({ + token, + origin: "https://forms.router.so/path", + contentType: "text/plain", + }), + params + ); + + expect(response.status).toBe(401); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(mocks.acceptLead).not.toHaveBeenCalled(); + }); + + it("cancels an oversized chunked body as soon as the payload ceiling is crossed", async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(64 * 1024 + 1)); + }, + cancel, + }); + const request = new Request( + `https://forms.router.so/api/public/forms/${publicId}/leads`, + { + method: "POST", + headers: { origin: "https://forms.router.so" }, + body, + duplex: "half", + } as RequestInit & { duplex: "half" } + ); + + const response = await submitLead(request, params); + + expect(response.status).toBe(413); + expect(cancel).toHaveBeenCalledOnce(); + expect(mocks.enforceFormRateLimit).not.toHaveBeenCalled(); + expect(mocks.acceptLead).not.toHaveBeenCalled(); + }); + + it.each([ + { placement: "hosted" as const, origin: "https://forms.router.so" }, + { placement: "embed" as const, origin: "https://site.example" }, + { placement: "wordpress" as const, origin: "https://wordpress.example" }, + ])("preserves legitimate $placement submissions", async ({ placement, origin }) => { + const token = createSubmissionToken( + { publicId, revision: 7, placement, origin }, + { secret } + ); + const response = await submitLead(leadRequest({ token, origin }), params); + + expect(response.status).toBe(200); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe(origin); + expect(mocks.acceptLead).toHaveBeenCalledWith( + expect.objectContaining({ publicId, placement, publishedRevision: 7 }) + ); + }); + + it("rejects a submission token minted for a stale published revision", async () => { + const token = createSubmissionToken( + { publicId, revision: 6, placement: "embed", origin: "https://site.example" }, + { secret } + ); + const response = await submitLead( + leadRequest({ token, origin: "https://site.example" }), + params + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: "stale_form_revision", + revision: 7, + }); + expect(mocks.acceptLead).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/stripe-subscription-state.test.ts b/__tests__/stripe-subscription-state.test.ts new file mode 100644 index 0000000..cbb3d61 --- /dev/null +++ b/__tests__/stripe-subscription-state.test.ts @@ -0,0 +1,201 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + endedSubscriptionState, + failedPaymentState, + invoiceSubscriptionId, + shouldApplySubscriptionEvent, + shouldClearScheduledCancellation, + stripeCheckoutMetadata, + subscriptionEntitlementState, +} from "../lib/forms/stripe-subscription-state"; +import { legacyMigrationDecision } from "../lib/forms/stripe-legacy-migration"; + +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +function subscription(priceId: string, cancelAtPeriodEnd = false) { + return { + priceId, + customerId: "cus_router", + subscriptionId: "sub_router", + status: "active", + createdAt: 1_700_000_000, + currentPeriodEnd: 1_800_000_000, + cancelAtPeriodEnd, + }; +} + +describe("Stripe entitlement transitions", () => { + it("reconciles already-scheduled legacy subscriptions only in apply mode", () => { + expect( + legacyMigrationDecision({ apply: true, cancelAtPeriodEnd: true }) + ).toEqual({ updateStripe: false, reconcileRouter: true }); + expect( + legacyMigrationDecision({ apply: false, cancelAtPeriodEnd: true }) + ).toEqual({ updateStripe: false, reconcileRouter: false }); + expect( + legacyMigrationDecision({ apply: true, cancelAtPeriodEnd: false }) + ).toEqual({ updateStripe: true, reconcileRouter: true }); + }); + + 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", "sub_router", 1_700_000_000) + ).toEqual({ + plan: "free", + stripeSubscriptionId: "sub_router", + stripeSubscriptionStatus: "canceled", + stripeSubscriptionCreatedAt: new Date(1_700_000_000 * 1_000), + 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" + ); + }); + + it("ignores events from a superseded subscription", () => { + const event = { + eventSubscriptionId: "sub_current", + eventCreatedAt: new Date("2026-09-02T12:00:00Z"), + }; + expect( + shouldApplySubscriptionEvent({ + storedSubscriptionId: null, + storedSubscriptionStatus: null, + storedSubscriptionCreatedAt: null, + ...event, + }) + ).toBe(true); + expect( + shouldApplySubscriptionEvent({ + storedSubscriptionId: "sub_current", + storedSubscriptionStatus: "active", + storedSubscriptionCreatedAt: new Date("2026-09-02T12:00:00Z"), + ...event, + }) + ).toBe(true); + expect( + shouldApplySubscriptionEvent({ + storedSubscriptionId: "sub_current", + storedSubscriptionStatus: "canceled", + storedSubscriptionCreatedAt: new Date("2026-09-02T12:00:00Z"), + ...event, + }) + ).toBe(false); + expect( + shouldApplySubscriptionEvent({ + storedSubscriptionId: "sub_current", + storedSubscriptionStatus: "active", + storedSubscriptionCreatedAt: new Date("2026-09-02T12:00:00Z"), + eventSubscriptionId: "sub_other", + eventCreatedAt: new Date("2026-09-03T12:00:00Z"), + }) + ).toBe(false); + expect( + shouldApplySubscriptionEvent({ + storedSubscriptionId: "sub_current", + storedSubscriptionStatus: "canceled", + storedSubscriptionCreatedAt: null, + eventSubscriptionId: "sub_unknown_age", + eventCreatedAt: new Date("2026-09-03T12:00:00Z"), + }) + ).toBe(false); + expect( + shouldApplySubscriptionEvent({ + storedSubscriptionId: "sub_current", + storedSubscriptionStatus: "canceled", + storedSubscriptionCreatedAt: new Date("2026-09-02T12:00:00Z"), + eventSubscriptionId: "sub_older", + eventCreatedAt: new Date("2026-09-01T12:00:00Z"), + }) + ).toBe(false); + expect( + shouldApplySubscriptionEvent({ + storedSubscriptionId: "sub_current", + storedSubscriptionStatus: "canceled", + storedSubscriptionCreatedAt: new Date("2026-09-02T12:00:00Z"), + eventSubscriptionId: "sub_new", + eventCreatedAt: new Date("2026-09-03T12:00:00Z"), + }) + ).toBe(true); + }); + + it("clears period-end cancellation only after selecting a new Router price", () => { + process.env.STRIPE_PRO_MONTHLY_PRICE_ID = "price_new_pro"; + + expect( + shouldClearScheduledCancellation({ + priceId: "price_new_pro", + cancelAtPeriodEnd: true, + legacyMigrationRequired: true, + }) + ).toBe(true); + expect( + shouldClearScheduledCancellation({ + priceId: "price_1QVIiNCr7fYvZ7eq3SRX0YGS", + cancelAtPeriodEnd: true, + legacyMigrationRequired: true, + }) + ).toBe(false); + expect( + shouldClearScheduledCancellation({ + priceId: "price_new_pro", + cancelAtPeriodEnd: false, + legacyMigrationRequired: true, + }) + ).toBe(false); + expect( + shouldClearScheduledCancellation({ + priceId: "price_new_pro", + cancelAtPeriodEnd: true, + legacyMigrationRequired: false, + }) + ).toBe(false); + }); + + it("extracts subscription identity from failed invoices", () => { + expect(invoiceSubscriptionId("sub_current")).toBe("sub_current"); + expect(invoiceSubscriptionId({ id: "sub_expanded" })).toBe("sub_expanded"); + expect(invoiceSubscriptionId(null)).toBeNull(); + }); + + it("identifies the Router user on checkout and subscription metadata", () => { + expect(stripeCheckoutMetadata("user_123", "business")).toEqual({ + routerUserId: "user_123", + routerPlan: "business", + }); + }); +}); diff --git a/__tests__/usage-notifications.test.ts b/__tests__/usage-notifications.test.ts new file mode 100644 index 0000000..7fa6d56 --- /dev/null +++ b/__tests__/usage-notifications.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + crossedUsageThresholds, + sendUsageThresholdNotification, +} 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([]); + }); + + 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/__tests__/validation.test.ts b/__tests__/validation.test.ts index 0264804..19d87d9 100644 --- a/__tests__/validation.test.ts +++ b/__tests__/validation.test.ts @@ -1,5 +1,80 @@ import { expect, test, describe } from "vitest"; import * as validation from "../lib/validation"; +import { updateEndpointFormSchema } from "../lib/data/validations"; +import { endpointSchemaForUpdate } from "../lib/forms/endpoint-schema"; + +describe("endpoint editor validation", () => { + test("preserves form-compiled required and constraint metadata", () => { + const parsed = updateEndpointFormSchema.parse({ + id: "endpoint_1", + name: "Contact", + schema: [ + { + key: "email", + value: "email", + required: false, + constraints: { minLength: 5, maxLength: 120 }, + }, + { + key: "consent", + value: "boolean", + required: true, + constraints: { mustBeTrue: true }, + }, + ], + formEnabled: false, + webhookEnabled: false, + }); + + expect(parsed.schema).toEqual([ + { + key: "email", + value: "email", + required: false, + constraints: { minLength: 5, maxLength: 120 }, + }, + { + key: "consent", + value: "boolean", + required: true, + constraints: { mustBeTrue: true }, + }, + ]); + }); + + test("keeps the compiled contract when attached endpoint settings are saved", () => { + const current = [ + { + key: "email", + value: "email" as const, + required: false, + constraints: { maxLength: 120 }, + }, + ]; + + expect( + endpointSchemaForUpdate( + current, + [{ key: "email", value: "email" }], + true + ) + ).toBe(current); + expect( + endpointSchemaForUpdate( + current, + [{ key: "renamed", value: "email" }], + true + ) + ).toBeNull(); + expect( + endpointSchemaForUpdate( + current, + [{ key: "renamed", value: "email" }], + false + ) + ).toEqual([{ key: "renamed", value: "email" }]); + }); +}); describe("convertToCorrectTypes", () => { test('should convert "true" and "false" strings to boolean values', () => { 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/forms-maintenance/route.ts b/app/api/cron/forms-maintenance/route.ts new file mode 100644 index 0000000..3ac4eb9 --- /dev/null +++ b/app/api/cron/forms-maintenance/route.ts @@ -0,0 +1,20 @@ +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 cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + return new Response("Maintenance is not configured", { status: 503 }); + } + const authHeader = request.headers.get("authorization"); + if (authHeader !== `Bearer ${cronSecret}`) { + 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/endpoints/[id]/route.ts b/app/api/endpoints/[id]/route.ts index f84888b..fd917f3 100644 --- a/app/api/endpoints/[id]/route.ts +++ b/app/api/endpoints/[id]/route.ts @@ -1,315 +1,121 @@ 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"; +import { + PayloadTooLargeError, + readLimitedJsonBody, +} from "@/lib/forms/request-body"; + +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 }); +} -/** - * 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 - ); + 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 } + ); + } - return NextResponse.json( - { errors: parsedData.error.format() }, - { status: 400 } - ); + try { + const values = await readLimitedJsonBody(request, MAX_BODY_BYTES); + const result = await acceptLead({ + endpointId: id, + values, + placement: "headless", + }); + return NextResponse.json({ success: true, id: result.leadId }); + } catch (error) { + if (error instanceof PayloadTooLargeError) { + return new NextResponse("Payload too large", { status: 413 }); } - - 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, - ]); - - 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 - ); - } + 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..e0b4764 --- /dev/null +++ b/app/api/public/forms/[publicId]/leads/route.ts @@ -0,0 +1,166 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getPublishedForm } from "@/lib/data/public-forms"; +import { + acceptLead, + LeadCapacityError, + LeadEndpointError, + LeadStaleRevisionError, + LeadValidationError, +} from "@/lib/forms/lead-acceptance"; +import { isHostedFormRequest, requestOrigin } from "@/lib/forms/origins"; +import { + isApprovedFormOrigin, + publicCorsHeaders, + publicFormOptionsResponse, +} from "@/lib/forms/public-access"; +import { enforceFormRateLimit, FormRateLimitError } from "@/lib/forms/rate-limit"; +import { + submissionTokenMatchesRequest, + verifySubmissionToken, +} from "@/lib/forms/submission-token"; +import { publicFormsEnabled } from "@/lib/forms/feature-flags"; +import { + PayloadTooLargeError, + readLimitedJsonBody, +} from "@/lib/forms/request-body"; + +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" + ); +} + +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 readLimitedJsonBody(request, MAX_BODY_BYTES) + ); + } catch (error) { + const status = error instanceof PayloadTooLargeError ? 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 (!submissionTokenMatchesRequest(token, { publicId, origin })) { + return NextResponse.json({ error: "invalid_submit_token" }, { status: 401 }); + } + if (token.placement === "hosted") { + if (!isHostedFormRequest(request)) { + return NextResponse.json({ error: "origin_not_approved" }, { status: 403 }); + } + } else { + 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, true); + if (token.revision !== form.revision) { + return NextResponse.json( + { error: "stale_form_revision", revision: form.revision }, + { status: 409, 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, + publishedRevision: token.revision, + 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 LeadStaleRevisionError) { + return NextResponse.json( + { error: "stale_form_revision", revision: error.currentRevision }, + { status: 409, 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; + return publicFormOptionsResponse(request, publicId); +} 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..891aa9f --- /dev/null +++ b/app/api/public/forms/[publicId]/render-session/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getPublishedForm } from "@/lib/data/public-forms"; +import { isHostedFormRequest, requestOrigin } from "@/lib/forms/origins"; +import { + isApprovedFormOrigin, + publicCorsHeaders, + publicFormOptionsResponse, +} 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"]), +}); + +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") { + approved = isHostedFormRequest(request); + } else if (origin) { + approved = await isApprovedFormOrigin({ + publicId, + origin, + placement: input.data.placement, + }); + } + + if (!approved || !origin) { + 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, + revision: form.revision, + placement: input.data.placement, + origin, + }), + revision: form.revision, + 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; + return publicFormOptionsResponse(request, publicId); +} diff --git a/app/api/public/forms/[publicId]/route.ts b/app/api/public/forms/[publicId]/route.ts new file mode 100644 index 0000000..c995568 --- /dev/null +++ b/app/api/public/forms/[publicId]/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { getPublishedForm } from "@/lib/data/public-forms"; +import { + isApprovedFormOrigin, + publicCorsHeaders, + publicFormOptionsResponse, +} 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, + { 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 = publishedFormEtag(published); + 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; + return publicFormOptionsResponse(request, publicId); +} diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index a4fd014..f57c579 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -1,152 +1,266 @@ import { headers } from "next/headers"; import { NextResponse } from "next/server"; -import { Stripe } from "stripe"; +import type Stripe from "stripe"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; import { db } from "@/lib/db"; import { users } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; -import { revalidatePath } from "next/cache"; -import { STRIPE_PLANS } from "@/lib/constants/stripe"; +import { + planForNewPrice, +} from "@/lib/constants/stripe"; +import { getStripe } from "@/lib/utils/stripe-client"; +import { getUserPublishedFormIds } from "@/lib/data/public-forms"; +import { invalidatePublishedForm } from "@/lib/forms/cache"; +import { + endedSubscriptionState, + failedPaymentState, + invoiceSubscriptionId, + isTerminalSubscriptionStatus, + shouldApplySubscriptionEvent, + shouldClearScheduledCancellation, + subscriptionEntitlementState, +} from "@/lib/forms/stripe-subscription-state"; + +type SubscriptionOwner = { + id: string; + stripeSubscriptionId: string | null; + stripeSubscriptionStatus: string | null; + stripeSubscriptionCreatedAt: Date | null; + legacyPriceMigrationRequired: boolean; +}; -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); -const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; +async function subscriptionOwner( + subscription: Stripe.Subscription, + fallback: { userId?: string; email?: string } = {} +): Promise { + const userCondition = subscription.metadata.routerUserId + ? eq(users.id, subscription.metadata.routerUserId) + : fallback.userId + ? eq(users.id, fallback.userId) + : fallback.email + ? eq(users.email, fallback.email) + : eq(users.stripeCustomerId, subscription.customer as string); + const [owner] = await db + .select({ + id: users.id, + stripeSubscriptionId: users.stripeSubscriptionId, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + stripeSubscriptionCreatedAt: users.stripeSubscriptionCreatedAt, + legacyPriceMigrationRequired: users.legacyPriceMigrationRequired, + }) + .from(users) + .where(userCondition) + .limit(1); + if ( + !owner || + !shouldApplySubscriptionEvent({ + storedSubscriptionId: owner.stripeSubscriptionId, + storedSubscriptionStatus: owner.stripeSubscriptionStatus, + storedSubscriptionCreatedAt: owner.stripeSubscriptionCreatedAt, + eventSubscriptionId: subscription.id, + eventCreatedAt: new Date(subscription.created * 1_000), + }) + ) { + return null; + } + return owner; +} + +function subscriptionSnapshotCondition(owner: SubscriptionOwner) { + return and( + eq(users.id, owner.id), + owner.stripeSubscriptionId === null + ? isNull(users.stripeSubscriptionId) + : eq(users.stripeSubscriptionId, owner.stripeSubscriptionId), + owner.stripeSubscriptionStatus === null + ? isNull(users.stripeSubscriptionStatus) + : eq(users.stripeSubscriptionStatus, owner.stripeSubscriptionStatus), + owner.stripeSubscriptionCreatedAt === null + ? isNull(users.stripeSubscriptionCreatedAt) + : eq(users.stripeSubscriptionCreatedAt, owner.stripeSubscriptionCreatedAt) + ); +} + +async function updateSubscription( + subscription: Stripe.Subscription, + stripe: Stripe, + options: { + fallback?: { userId?: string; email?: string }; + refresh?: boolean; + } = {} +) { + const owner = await subscriptionOwner(subscription, options.fallback); + if (!owner) return; + let currentSubscription = + options.refresh === false + ? subscription + : await stripe.subscriptions.retrieve(subscription.id); + let priceId = currentSubscription.items.data[0]?.price.id; + if (!priceId) throw new Error("Subscription has no price."); + if ( + shouldClearScheduledCancellation({ + priceId, + cancelAtPeriodEnd: currentSubscription.cancel_at_period_end, + legacyMigrationRequired: owner.legacyPriceMigrationRequired, + }) + ) { + currentSubscription = await stripe.subscriptions.update(subscription.id, { + cancel_at_period_end: false, + }); + priceId = currentSubscription.items.data[0]?.price.id; + if (!priceId) throw new Error("Subscription has no price."); + } + const state = isTerminalSubscriptionStatus(currentSubscription.status) + ? endedSubscriptionState( + currentSubscription.status, + currentSubscription.id, + currentSubscription.created + ) + : subscriptionEntitlementState({ + priceId, + customerId: currentSubscription.customer as string, + subscriptionId: currentSubscription.id, + status: currentSubscription.status, + createdAt: currentSubscription.created, + currentPeriodEnd: currentSubscription.current_period_end, + cancelAtPeriodEnd: currentSubscription.cancel_at_period_end, + }); + + const [updated] = await db + .update(users) + .set(state) + .where(subscriptionSnapshotCondition(owner)) + .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 session = event.data.object; + const subscriptionId = + typeof session.subscription === "string" + ? session.subscription + : session.subscription?.id; + if (!subscriptionId) throw new Error("Checkout has no subscription."); + if (!session.metadata?.routerUserId && !session.customer_details?.email) { + throw new Error("Checkout has no Router user or customer email."); } - - const customerEmail = session.customer_email; - if (!customerEmail) { - throw new Error("No customer email found in session"); + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const priceId = subscription.items.data[0]?.price.id; + if (!priceId || !planForNewPrice(priceId)) { + throw new Error("Checkout used an unrecognized or retired price."); } - - await db - .update(users) - .set({ - plan, - stripeCustomerId: session.customer as string, - }) - .where(eq(users.email, customerEmail)); + await updateSubscription(subscription, stripe, { + fallback: { + userId: session.metadata?.routerUserId, + email: session.customer_details?.email ?? undefined, + }, + refresh: false, + }); } - // 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}`); - } - - 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, stripe); } - // 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 owner = await subscriptionOwner(subscription); + if (!owner) { + return NextResponse.json({ success: true, ignored: "superseded_subscription" }); + } + const [updated] = await db .update(users) - .set({ plan: "free" }) - .where(eq(users.stripeCustomerId, subscription.customer as string)); + .set( + endedSubscriptionState( + subscription.status, + subscription.id, + subscription.created + ) + ) + .where(subscriptionSnapshotCondition(owner)) + .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; + const subscriptionId = invoiceSubscriptionId(invoice.subscription); + if (subscriptionId) { + const [owner] = await db + .select({ + id: users.id, + stripeSubscriptionId: users.stripeSubscriptionId, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + stripeSubscriptionCreatedAt: users.stripeSubscriptionCreatedAt, + legacyPriceMigrationRequired: users.legacyPriceMigrationRequired, + }) + .from(users) + .where(eq(users.stripeCustomerId, invoice.customer as string)) + .limit(1); + if ( + owner?.stripeSubscriptionId === subscriptionId && + !isTerminalSubscriptionStatus(owner.stripeSubscriptionStatus) + ) { + await db + .update(users) + .set(failedPaymentState()) + .where(subscriptionSnapshotCondition(owner)); + } + } } - // 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, + stripeSubscriptionCreatedAt: 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..7b4c825 100644 --- a/app/endpoints/[id]/page.tsx +++ b/app/endpoints/[id]/page.tsx @@ -19,6 +19,11 @@ 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"; +import { isEndpointSchemaCompatible } from "@/lib/forms/starters"; const pageData = { title: "Endpoint", @@ -34,12 +39,14 @@ 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 if (!endpointData || serverError) notFound(); const schema = endpointData?.schema as GeneralSchema[]; + const endpointSupportsForm = isEndpointSchemaCompatible(schema); const url = `https://app.router.so/api/endpoints/${endpointData.id}`; @@ -88,6 +95,32 @@ export default async function Page({
{`${pageData?.description}`}
+ {formsNavigationEnabled() && + (attachedForm?.data || endpointSupportsForm) && ( +
+
+

+ {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..0a7979b --- /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/public-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( + () => + form.publishedDefinition + ? hasEndpointSchemaChanged(definition, form.publishedDefinition) + : hasEndpointSchemaChangedFromEndpoint(definition, form.endpointSchema), + [definition, form.endpointSchema, 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.map((item) => item.key)); + 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) 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 = `${normalizeSubmissionKey(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 0000000..a6e7fc7 Binary files /dev/null and b/dogfood-output/screenshots/desktop-fixed.png differ diff --git a/dogfood-output/screenshots/desktop-initial.png b/dogfood-output/screenshots/desktop-initial.png new file mode 100644 index 0000000..73415d8 Binary files /dev/null and b/dogfood-output/screenshots/desktop-initial.png differ diff --git a/dogfood-output/screenshots/mobile-reduced-motion.png b/dogfood-output/screenshots/mobile-reduced-motion.png new file mode 100644 index 0000000..4d4e4da Binary files /dev/null and b/dogfood-output/screenshots/mobile-reduced-motion.png differ diff --git a/e2e/forms-runtime.spec.ts b/e2e/forms-runtime.spec.ts new file mode 100644 index 0000000..d58696d --- /dev/null +++ b/e2e/forms-runtime.spec.ts @@ -0,0 +1,412 @@ +import { readFileSync } from "node:fs"; +import { expect, test, type Page } from "@playwright/test"; +import { FORM_STARTERS } from "../lib/forms/starters"; +import type { FormDefinitionV1 } from "../lib/forms/definition"; + +const runtimeSource = readFileSync("public/embed/v1.js", "utf8"); +const publicId = "browser-form"; +const allFieldsDefinition: FormDefinitionV1 = { + version: 1, + title: "Browser matrix form", + description: "Every supported field type.", + fields: [ + { id: "name", key: "name", kind: "text", label: "Name", required: true }, + { id: "email", key: "email", kind: "email", label: "Email", required: true }, + { id: "phone", key: "phone", kind: "phone", label: "Phone", required: true }, + { id: "url", key: "url", kind: "url", label: "Website", required: true }, + { id: "date", key: "date", kind: "date", label: "Date", required: true }, + { id: "count", key: "count", kind: "number", label: "Count", required: true }, + { id: "notes", key: "notes", kind: "textarea", label: "Notes", required: true }, + { + id: "select", + key: "select", + kind: "select", + label: "Select topic", + required: true, + options: [{ id: "sales", label: "Sales", value: "sales" }], + }, + { + id: "radio", + key: "radio", + kind: "radio", + label: "Radio topic", + required: true, + options: [{ id: "alpha", label: "Alpha", value: "alpha" }], + }, + { id: "consent", key: "consent", kind: "checkbox", label: "Consent", required: true }, + { + id: "groups", + key: "groups", + kind: "checkbox-group", + label: "Groups", + required: true, + options: [{ id: "one", label: "Group one", value: "one" }], + }, + { id: "decision", key: "decision", kind: "yes-no", label: "Decision", required: true }, + { id: "updates", key: "updates", kind: "switch", label: "Updates", required: false }, + { + id: "score", + key: "score", + kind: "slider", + label: "Score", + required: true, + validation: { min: 0, max: 10, step: 1 }, + }, + ], + submitLabel: "Send response", + completion: { type: "message", message: "Browser submission accepted." }, +}; + +async function installRuntimeMocks( + page: Page, + input: { + siteUrl: string; + responseStatus?: number; + responseBody?: Record; + definition?: FormDefinitionV1; + definitionStatus?: number; + sessionRevision?: number; + staleDefinition?: FormDefinitionV1; + } +) { + const definition = input.definition ?? allFieldsDefinition; + const sessionRevision = input.sessionRevision ?? 1; + let submitted: Record | null = null; + let requestedPlacement: string | null = null; + await page.route("https://forms.router.so/embed/v1.js", (route) => + route.fulfill({ contentType: "application/javascript", body: runtimeSource }) + ); + await page.route("https://forms.router.so/api/public/forms/**", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "Content-Type", + }, + }); + return; + } + const headers = { "access-control-allow-origin": "*" }; + if (route.request().url().endsWith("/render-session")) { + requestedPlacement = (route.request().postDataJSON() as { placement: string }).placement; + await route.fulfill({ + contentType: "application/json", + headers, + body: JSON.stringify({ + submitToken: "browser-token", + revision: sessionRevision, + expiresIn: 3600, + }), + }); + return; + } + if (route.request().url().endsWith("/leads")) { + submitted = route.request().postDataJSON() as Record; + const status = input.responseStatus ?? 200; + await route.fulfill({ + status, + contentType: "application/json", + headers, + body: JSON.stringify( + input.responseBody ?? (status === 429 + ? { error: "monthly_capacity_reached" } + : { leadId: "lead-browser", completion: definition.completion }) + ), + }); + return; + } + const requestedDefinition = + input.staleDefinition && new URL(route.request().url()).searchParams.has("revision") + ? definition + : input.staleDefinition ?? definition; + await route.fulfill({ + status: input.definitionStatus ?? 200, + contentType: "application/json", + headers, + body: JSON.stringify({ + publicId, + revision: requestedDefinition === definition ? sessionRevision : 1, + definition: requestedDefinition, + attribution: { visible: false }, + }), + }); + }); + return { + submitted: () => submitted, + requestedPlacement: () => requestedPlacement, + }; +} + +async function openPlacement( + page: Page, + input: { + siteUrl: string; + placement: "hosted" | "embed" | "wordpress"; + definition?: FormDefinitionV1; + } +) { + const definition = input.definition ?? allFieldsDefinition; + await page.route(input.siteUrl, (route) => + route.fulfill({ + contentType: "text/html", + body: ` +
+ + `, + }) + ); + await page.goto(input.siteUrl); + await expect(page.getByRole("heading", { name: definition.title })).toBeVisible(); +} + +async function completeEveryField(page: Page) { + const mount = page.locator(`[data-router-form="${publicId}"]`); + await mount.getByLabel("Name").fill("Ada Lovelace"); + await mount.getByLabel("Email").fill("ada@example.com"); + await mount.getByLabel("Phone").fill("+12025550123"); + await mount.getByLabel("Website").fill("https://example.com"); + await mount.getByLabel(/^Date/).fill("2026-09-01"); + await mount.getByLabel("Count").fill("4"); + await mount.getByLabel("Notes").fill("A browser-tested response."); + await mount.getByLabel("Select topic").selectOption("sales"); + await mount.getByRole("radio", { name: "Alpha" }).check(); + await mount.getByRole("checkbox", { name: /Consent/ }).check(); + await mount.getByRole("checkbox", { name: "Group one" }).check(); + await mount.getByRole("radio", { name: "Yes" }).check(); + await mount.getByRole("checkbox", { name: "Updates" }).check(); + await mount.getByLabel("Score").fill("7"); + await mount.getByRole("button", { name: "Send response" }).click(); +} + +const placements = [ + { name: "hosted", placement: "hosted" as const, siteUrl: "https://forms.router.so/browser-form" }, + { name: "generic embed", placement: "embed" as const, siteUrl: "https://site.example/form" }, +]; + +for (const scenario of placements) { + test(`${scenario.name} renders and submits every field type`, async ({ page }) => { + const observed = await installRuntimeMocks(page, { siteUrl: scenario.siteUrl }); + await openPlacement(page, scenario); + await completeEveryField(page); + + await expect(page.getByText("Browser submission accepted.")).toBeVisible(); + expect(observed.requestedPlacement()).toBe(scenario.placement); + expect(observed.submitted()).toMatchObject({ + values: { + name: "Ada Lovelace", + email: "ada@example.com", + consent: true, + groups: ["one"], + decision: true, + score: 7, + }, + submitToken: "browser-token", + }); + }); +} + +for (const scenario of placements) { + for (const [starterId, starterDefinition] of Object.entries(FORM_STARTERS)) { + test(`${starterId} starter renders in ${scenario.name}`, async ({ page }) => { + const siteUrl = `${scenario.siteUrl}/starters/${starterId}`; + await installRuntimeMocks(page, { siteUrl, definition: starterDefinition }); + await openPlacement(page, { + siteUrl, + placement: scenario.placement, + definition: starterDefinition, + }); + + await expect( + page.getByRole("button", { name: starterDefinition.submitLabel }) + ).toBeVisible(); + }); + } +} + +test("shows quota-paused and Router-unavailable states", async ({ page }) => { + await installRuntimeMocks(page, { + siteUrl: "https://site.example/quota", + responseStatus: 429, + }); + await openPlacement(page, { + siteUrl: "https://site.example/quota", + placement: "embed", + }); + await completeEveryField(page); + await expect(page.getByText("This form is temporarily paused.")).toBeVisible(); + + const unavailable = await page.context().newPage(); + await unavailable.route("https://forms.router.so/embed/v1.js", (route) => + route.fulfill({ contentType: "application/javascript", body: runtimeSource }) + ); + await unavailable.route("https://forms.router.so/api/public/forms/**", (route) => + route.fulfill({ status: 503, contentType: "application/json", body: "{}" }) + ); + await unavailable.route("https://site.example/unavailable", (route) => + route.fulfill({ + contentType: "text/html", + body: `
`, + }) + ); + await unavailable.goto("https://site.example/unavailable"); + await expect(unavailable.getByText("This form is unavailable.")).toBeVisible(); +}); + +test("shows server field errors accessibly and focuses the invalid control", async ({ + page, +}) => { + await installRuntimeMocks(page, { + siteUrl: "https://site.example/validation", + responseStatus: 400, + responseBody: { + error: "validation_failed", + fields: { email: ["This email is already registered."] }, + }, + }); + await openPlacement(page, { + siteUrl: "https://site.example/validation", + placement: "embed", + }); + await completeEveryField(page); + + const email = page.getByLabel("Email"); + await expect(page.getByRole("alert")).toHaveText( + "This email is already registered." + ); + await expect(email).toBeFocused(); + await expect(email).toHaveAttribute("aria-invalid", "true"); +}); + +test("follows a validated completion redirect", async ({ page }) => { + const redirectDefinition: FormDefinitionV1 = { + ...allFieldsDefinition, + completion: { type: "redirect", url: "https://site.example/thanks" }, + }; + await page.route("https://site.example/thanks", (route) => + route.fulfill({ contentType: "text/html", body: "Redirect complete" }) + ); + await installRuntimeMocks(page, { + siteUrl: "https://site.example/redirect", + definition: redirectDefinition, + }); + await openPlacement(page, { + siteUrl: "https://site.example/redirect", + placement: "embed", + definition: redirectDefinition, + }); + await completeEveryField(page); + + await expect(page).toHaveURL("https://site.example/thanks"); + await expect(page.getByText("Redirect complete")).toBeVisible(); +}); + +test("refreshes a stale cached definition before rendering", async ({ page }) => { + const currentDefinition: FormDefinitionV1 = { + ...allFieldsDefinition, + title: "Current form revision", + }; + await installRuntimeMocks(page, { + siteUrl: "https://site.example/stale", + definition: currentDefinition, + staleDefinition: { ...allFieldsDefinition, title: "Stale cached revision" }, + sessionRevision: 2, + }); + await openPlacement(page, { + siteUrl: "https://site.example/stale", + placement: "embed", + definition: currentDefinition, + }); + + await expect(page.getByText("Stale cached revision")).toHaveCount(0); +}); + +test("shows an unavailable state for a disabled or unpublished form", async ({ + page, +}) => { + const siteUrl = "https://site.example/disabled"; + await installRuntimeMocks(page, { siteUrl, definitionStatus: 404 }); + await page.route(siteUrl, (route) => + route.fulfill({ + contentType: "text/html", + body: `
`, + }) + ); + await page.goto(siteUrl); + + await expect(page.getByText("This form is unavailable.")).toBeVisible(); +}); + +test("exposes a loading state until the definition and render session arrive", async ({ + page, +}) => { + let releaseResponses!: () => void; + const responseGate = new Promise((resolve) => { + releaseResponses = resolve; + }); + await page.route("https://forms.router.so/embed/v1.js", (route) => + route.fulfill({ contentType: "application/javascript", body: runtimeSource }) + ); + await page.route("https://forms.router.so/api/public/forms/**", async (route) => { + await responseGate; + if (route.request().url().endsWith("/render-session")) { + await route.fulfill({ + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ + submitToken: "loading-token", + revision: 1, + expiresIn: 3600, + }), + }); + return; + } + await route.fulfill({ + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ + publicId, + revision: 1, + definition: allFieldsDefinition, + attribution: { visible: false }, + }), + }); + }); + const siteUrl = "https://site.example/loading"; + await page.route(siteUrl, (route) => + route.fulfill({ + contentType: "text/html", + body: `
`, + }) + ); + + await page.goto(siteUrl); + const mount = page.locator(`[data-router-form="${publicId}"]`); + await expect(mount).toHaveAttribute("aria-busy", "true"); + releaseResponses(); + await expect( + mount.getByRole("heading", { name: allFieldsDefinition.title }) + ).toBeVisible(); + await expect(mount).not.toHaveAttribute("aria-busy", "true"); +}); + +test("preserves the entered form and reports a revision race", async ({ page }) => { + await installRuntimeMocks(page, { + siteUrl: "https://site.example/revision-race", + responseStatus: 409, + responseBody: { error: "stale_form_revision", revision: 2 }, + }); + await openPlacement(page, { + siteUrl: "https://site.example/revision-race", + placement: "embed", + }); + await completeEveryField(page); + + await expect( + page.getByRole("alert").filter({ + hasText: "This form changed while you were filling it out.", + }) + ).toBeVisible(); + await expect(page.getByLabel("Name")).toHaveValue("Ada Lovelace"); +}); diff --git a/e2e/wordpress-runtime.spec.ts b/e2e/wordpress-runtime.spec.ts new file mode 100644 index 0000000..2b6ae91 --- /dev/null +++ b/e2e/wordpress-runtime.spec.ts @@ -0,0 +1,331 @@ +import { readFileSync } from "node:fs"; +import { expect, test, type Page } from "@playwright/test"; +import type { FormDefinitionV1 } from "../lib/forms/definition"; +import { FORM_STARTERS } from "../lib/forms/starters"; + +const wordpressBaseUrl = process.env.WORDPRESS_BASE_URL; +const runtimeSource = readFileSync("public/embed/v1.js", "utf8"); +const publicId = "browser-form"; +const definition: FormDefinitionV1 = { + version: 1, + title: "Browser matrix form", + description: "Every supported field type rendered by WordPress.", + fields: [ + { id: "name", key: "name", kind: "text", label: "Name", required: true }, + { id: "email", key: "email", kind: "email", label: "Email", required: true }, + { id: "phone", key: "phone", kind: "phone", label: "Phone", required: true }, + { id: "url", key: "url", kind: "url", label: "Website", required: true }, + { id: "date", key: "date", kind: "date", label: "Date", required: true }, + { id: "count", key: "count", kind: "number", label: "Count", required: true }, + { id: "notes", key: "notes", kind: "textarea", label: "Notes", required: true }, + { + id: "select", + key: "select", + kind: "select", + label: "Select topic", + required: true, + options: [{ id: "sales", label: "Sales", value: "sales" }], + }, + { + id: "radio", + key: "radio", + kind: "radio", + label: "Radio topic", + required: true, + options: [{ id: "alpha", label: "Alpha", value: "alpha" }], + }, + { id: "consent", key: "consent", kind: "checkbox", label: "Consent", required: true }, + { + id: "groups", + key: "groups", + kind: "checkbox-group", + label: "Groups", + required: true, + options: [{ id: "one", label: "Group one", value: "one" }], + }, + { id: "decision", key: "decision", kind: "yes-no", label: "Decision", required: true }, + { id: "updates", key: "updates", kind: "switch", label: "Updates", required: false }, + { + id: "score", + key: "score", + kind: "slider", + label: "Score", + required: true, + validation: { min: 0, max: 10, step: 1 }, + }, + ], + submitLabel: "Send response", + completion: { type: "message", message: "WordPress submission accepted." }, +}; + +type WordPressWindow = Window & { + wp?: { + apiFetch: (input: { path: string }) => Promise; + data: { + select: (store: string) => { getBlocks: () => Array<{ clientId: string }> }; + dispatch: (store: string) => { + selectBlock?: (clientId: string) => void; + openGeneralSidebar?: (name: string) => void; + }; + }; + }; +}; + +async function installRouterMocks(page: Page) { + const placements: string[] = []; + await page.route("https://forms.router.so/embed/v1.js*", (route) => + route.fulfill({ contentType: "application/javascript", body: runtimeSource }) + ); + await page.route("https://forms.router.so/api/public/forms/**", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "Content-Type", + }, + }); + return; + } + + const headers = { "access-control-allow-origin": "*" }; + const requestUrl = new URL(route.request().url()); + const pathParts = requestUrl.pathname.split("/").filter(Boolean); + const requestedPublicId = pathParts[pathParts.indexOf("forms") + 1]; + const starterId = requestedPublicId.startsWith("starter-") + ? requestedPublicId.slice("starter-".length) + : null; + const requestedDefinition = starterId && starterId in FORM_STARTERS + ? FORM_STARTERS[starterId as keyof typeof FORM_STARTERS] + : definition; + if (route.request().url().endsWith("/render-session")) { + placements.push( + (route.request().postDataJSON() as { placement: string }).placement + ); + await route.fulfill({ + contentType: "application/json", + headers, + body: JSON.stringify({ + submitToken: "wordpress-token", + revision: 1, + expiresIn: 3600, + }), + }); + return; + } + if (route.request().url().endsWith("/leads")) { + await route.fulfill({ + contentType: "application/json", + headers, + body: JSON.stringify({ + leadId: "lead-wordpress", + completion: requestedDefinition.completion, + }), + }); + return; + } + await route.fulfill({ + contentType: "application/json", + headers, + body: JSON.stringify({ + publicId: requestedPublicId, + revision: 1, + definition: requestedDefinition, + attribution: { visible: false }, + }), + }); + }); + return placements; +} + +async function completeEveryField(page: Page) { + const mount = page.locator(`[data-router-form="${publicId}"]`); + await mount.getByLabel("Name").fill("Ada Lovelace"); + await mount.getByLabel("Email").fill("ada@example.com"); + await mount.getByLabel("Phone").fill("+12025550123"); + await mount.getByLabel("Website").fill("https://example.com"); + await mount.getByLabel(/^Date/).fill("2026-09-01"); + await mount.getByLabel("Count").fill("4"); + await mount.getByLabel("Notes").fill("A WordPress browser response."); + await mount.getByLabel("Select topic").selectOption("sales"); + await mount.getByRole("radio", { name: "Alpha" }).check(); + await mount.getByRole("checkbox", { name: /Consent/ }).check(); + await mount.getByRole("checkbox", { name: "Group one" }).check(); + await mount.getByRole("radio", { name: "Yes" }).check(); + await mount.getByRole("checkbox", { name: "Updates" }).check(); + await mount.getByLabel("Score").fill("7"); + await mount.getByRole("button", { name: definition.submitLabel }).click(); +} + +async function login(page: Page) { + await page.goto(`${wordpressBaseUrl}/wp-login.php`); + await page.locator("#user_login").fill("admin"); + await page.locator("#user_pass").fill("password"); + await Promise.all([ + page.waitForURL(/\/wp-admin\//), + page.locator("#wp-submit").click(), + ]); +} + +async function saveSiteToken(page: Page, token: string) { + await page.goto( + `${wordpressBaseUrl}/wp-admin/options-general.php?page=router-forms` + ); + await page.locator("#router-forms-token").fill(token); + await Promise.all([ + page.waitForURL(/settings-updated=true/), + page.locator("#submit").click(), + ]); + await expect(page.getByText("Settings saved.")).toBeVisible(); +} + +async function blockPageId(page: Page): Promise { + const response = await page.request.get( + `${wordpressBaseUrl}/wp-json/wp/v2/pages?slug=router-forms-block` + ); + expect(response.ok()).toBe(true); + const pages = (await response.json()) as Array<{ id: number }>; + expect(pages).toHaveLength(1); + return pages[0].id; +} + +async function expectEditorPreview(page: Page) { + await expect + .poll( + async () => { + for (const frame of page.frames()) { + const heading = frame.getByRole("heading", { name: definition.title }); + if ((await heading.count()) && (await heading.first().isVisible())) { + return true; + } + } + return false; + }, + { timeout: 20_000 } + ) + .toBe(true); +} + +test.describe("Router Forms in WordPress", () => { + test.skip(!wordpressBaseUrl, "WORDPRESS_BASE_URL is set by the wp-env matrix."); + test.describe.configure({ mode: "serial" }); + + test("connects through the local proxy and renders the Gutenberg picker and preview", async ({ + page, + }) => { + await installRouterMocks(page); + await login(page); + await saveSiteToken(page, "secret-test-token"); + + const postId = await blockPageId(page); + await page.goto(`${wordpressBaseUrl}/wp-admin/post.php?post=${postId}&action=edit`); + await page.waitForFunction( + () => Boolean((window as WordPressWindow).wp?.apiFetch) + ); + + const response = await page.evaluate(() => + (window as WordPressWindow).wp!.apiFetch({ path: "/router-forms/v1/forms" }) + ); + expect(response).toEqual({ + forms: [ + { + publicId, + name: "Browser Matrix", + title: definition.title, + revision: 1, + }, + ], + }); + await expectEditorPreview(page); + + await page.evaluate(() => { + const wordpress = (window as WordPressWindow).wp!; + const firstBlock = wordpress.data.select("core/block-editor").getBlocks()[0]; + wordpress.data.dispatch("core/block-editor").selectBlock?.(firstBlock.clientId); + wordpress.data + .dispatch("core/edit-post") + .openGeneralSidebar?.("edit-post/block"); + }); + await expect(page.getByLabel("Published form")).toHaveValue(publicId); + + await saveSiteToken(page, "revoked-test-token"); + await page.goto(`${wordpressBaseUrl}/wp-admin/post.php?post=${postId}&action=edit`); + await page.waitForFunction( + () => Boolean((window as WordPressWindow).wp?.apiFetch) + ); + const revokedMessage = await page.evaluate(async () => { + try { + await (window as WordPressWindow).wp!.apiFetch({ + path: "/router-forms/v1/forms", + }); + return null; + } catch (error) { + if (error instanceof Error) return error.message; + if (error && typeof error === "object" && "message" in error) { + return String(error.message); + } + return JSON.stringify(error); + } + }); + expect(revokedMessage).toContain("invalid or revoked"); + + await saveSiteToken(page, "secret-test-token"); + }); + + for (const slug of ["router-forms-shortcode", "router-forms-block"]) { + test(`${slug} renders and submits through the production runtime`, async ({ + page, + }) => { + const placements = await installRouterMocks(page); + await page.goto(`${wordpressBaseUrl}/${slug}/`); + await expect(page.getByRole("heading", { name: definition.title })).toBeVisible(); + expect(placements).toContain("wordpress"); + + const themeInheritance = await page + .locator(".router-form-v1") + .evaluate((root) => { + const rootStyle = getComputedStyle(root); + const bodyStyle = getComputedStyle(document.body); + return { + rootFont: rootStyle.fontFamily, + bodyFont: bodyStyle.fontFamily, + rootColor: rootStyle.color, + bodyColor: bodyStyle.color, + }; + }); + expect(themeInheritance.rootFont).toBe(themeInheritance.bodyFont); + expect(themeInheritance.rootColor).toBe(themeInheritance.bodyColor); + + await completeEveryField(page); + await expect(page.getByText("WordPress submission accepted.")).toBeVisible(); + expect(await page.content()).not.toContain("secret-test-token"); + }); + } + + test("initializes multiple WordPress forms on one page", async ({ page }) => { + const placements = await installRouterMocks(page); + await page.goto(`${wordpressBaseUrl}/router-forms-multiple/`); + await expect(page.getByRole("heading", { name: definition.title })).toHaveCount(2); + expect(placements.filter((placement) => placement === "wordpress")).toHaveLength( + 2 + ); + }); + + for (const placement of ["shortcode", "block"] as const) { + test(`${placement} renders every code-defined starter`, async ({ page }) => { + await installRouterMocks(page); + await page.goto(`${wordpressBaseUrl}/router-forms-${placement}-starters/`); + + for (const [starterId, starter] of Object.entries(FORM_STARTERS)) { + const mount = page.locator(`[data-router-form="starter-${starterId}"]`); + await expect( + mount.getByRole("heading", { name: starter.title }) + ).toBeVisible(); + await expect( + mount.getByRole("button", { name: starter.submitLabel }) + ).toBeVisible(); + } + }); + } +}); 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.asset.php b/integrations/wordpress/router-forms/editor.asset.php new file mode 100644 index 0000000..3522650 --- /dev/null +++ b/integrations/wordpress/router-forms/editor.asset.php @@ -0,0 +1,11 @@ + array( + 'wp-api-fetch', + 'wp-block-editor', + 'wp-blocks', + 'wp-components', + 'wp-element', + ), + 'version' => '1.0.0', +); diff --git a/integrations/wordpress/router-forms/editor.js b/integrations/wordpress/router-forms/editor.js new file mode 100644 index 0000000..7545b48 --- /dev/null +++ b/integrations/wordpress/router-forms/editor.js @@ -0,0 +1,98 @@ +(function (blocks, element, components, blockEditor, apiFetch) { + 'use strict'; + var el = element.createElement; + var useEffect = element.useEffect; + var useRef = element.useRef; + 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 || ''; + var previewRef = useRef(null); + + 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; + function mountPreview() { + if (previewRef.current && window.RouterFormsV1) { + window.RouterFormsV1.mount(previewRef.current); + } + } + var existing = document.querySelector('script[data-router-forms-editor]'); + if (existing) { + if (window.RouterFormsV1) mountPreview(); + else existing.addEventListener('load', mountPreview); + return function () { + existing.removeEventListener('load', mountPreview); + }; + } + var script = document.createElement('script'); + script.src = 'https://forms.router.so/embed/v1.js'; + script.async = true; + script.dataset.routerFormsEditor = 'true'; + script.addEventListener('load', mountPreview); + document.head.appendChild(script); + return function () { + script.removeEventListener('load', mountPreview); + }; + }, [formId, loading, error]); + + 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, ref: previewRef }); + + 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/integrations/wordpress/test-fixtures/router-forms-test-api/router-forms-test-api.php b/integrations/wordpress/test-fixtures/router-forms-test-api/router-forms-test-api.php new file mode 100644 index 0000000..f6650bd --- /dev/null +++ b/integrations/wordpress/test-fixtures/router-forms-test-api/router-forms-test-api.php @@ -0,0 +1,43 @@ + array(array( + 'publicId' => 'browser-form', + 'name' => 'Browser Matrix', + 'title' => 'Browser matrix form', + 'revision' => 1, + )), + )) + : wp_json_encode(array('error' => 'invalid_or_revoked_site_token')); + + return array( + 'headers' => array('content-type' => 'application/json'), + 'body' => $body, + 'response' => array( + 'code' => $status, + 'message' => $authorized ? 'OK' : 'Unauthorized', + ), + 'cookies' => array(), + 'filename' => null, + ); +} +add_filter('pre_http_request', 'router_forms_test_api_response', 10, 3); diff --git a/integrations/wordpress/test-matrix.sh b/integrations/wordpress/test-matrix.sh new file mode 100755 index 0000000..b930799 --- /dev/null +++ b/integrations/wordpress/test-matrix.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env sh +set -eu + +wp_env_config=$1 +theme_slug=$2 + +pnpm exec wp-env run cli --config="$wp_env_config" -- wp theme install "$theme_slug" --activate --force +pnpm exec wp-env run cli --config="$wp_env_config" -- wp plugin activate router-forms +pnpm exec wp-env run cli --config="$wp_env_config" -- wp plugin activate router-forms-test-api +pnpm exec wp-env run cli --config="$wp_env_config" -- wp eval ' +$registry = WP_Block_Type_Registry::get_instance(); +if (!$registry->is_registered("router/forms")) { + fwrite(STDERR, "Router Forms block was not registered.\n"); + exit(1); +} +$shortcode = do_shortcode("[router_form id=browser-form]"); +$block = render_block(array( + "blockName" => "router/forms", + "attrs" => array("formId" => "browser-form"), + "innerBlocks" => array(), + "innerHTML" => "", + "innerContent" => array(), +)); +$combined = $shortcode . $block; +if (substr_count($combined, "data-router-form=\"browser-form\"") !== 2) { + fwrite(STDERR, "Block and shortcode did not produce matching mount points.\n"); + exit(1); +} +update_option("router_forms_site_token", "secret-test-token"); +if (strpos($combined, "secret-test-token") !== false) { + fwrite(STDERR, "The site token leaked into frontend markup.\n"); + exit(1); +} +echo "Router Forms WordPress smoke passed.\n"; +' +pnpm exec wp-env run cli --config="$wp_env_config" -- wp option update permalink_structure '/%postname%/' +pnpm exec wp-env run cli --config="$wp_env_config" -- wp rewrite flush --hard +pnpm exec wp-env run cli --config="$wp_env_config" -- wp eval ' +$pages = array( + "router-forms-shortcode" => array("Router Forms Shortcode", "[router_form id=\"browser-form\"]"), + "router-forms-block" => array("Router Forms Block", ""), + "router-forms-multiple" => array("Router Forms Multiple", "[router_form id=\"browser-form\"]"), +); +$starter_ids = array("blank", "contact", "lead-capture", "feedback", "newsletter"); +$starter_shortcodes = ""; +$starter_blocks = ""; +foreach ($starter_ids as $starter_id) { + $public_id = "starter-" . $starter_id; + $starter_shortcodes .= "[router_form id=\"" . $public_id . "\"]"; + $starter_blocks .= ""; +} +$pages["router-forms-shortcode-starters"] = array("Router Forms Shortcode Starters", $starter_shortcodes); +$pages["router-forms-block-starters"] = array("Router Forms Block Starters", $starter_blocks); +foreach ($pages as $slug => $page) { + $existing = get_page_by_path($slug, OBJECT, "page"); + $result = wp_insert_post(array( + "ID" => $existing ? $existing->ID : 0, + "post_type" => "page", + "post_status" => "publish", + "post_title" => $page[0], + "post_name" => $slug, + "post_content" => $page[1], + ), true); + if (is_wp_error($result)) { + fwrite(STDERR, $result->get_error_message() . "\n"); + exit(1); + } +} +' +WORDPRESS_BASE_URL="http://localhost:8888" pnpm exec playwright test e2e/wordpress-runtime.spec.ts --project=chromium --workers=1 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..ce10bac 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 { eq, desc, and } from "drizzle-orm"; +import { endpoints, forms } from "../db/schema"; +import { eq, desc, and, isNotNull } 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, @@ -13,6 +13,30 @@ import { } from "./validations"; import { randomBytes } from "crypto"; import { redirect } from "next/navigation"; +import { invalidatePublishedForm } from "@/lib/forms/cache"; +import { endpointSchemaForUpdate } from "@/lib/forms/endpoint-schema"; +import { + AttachedFormExistsError, + deleteEndpointForUser, +} from "@/lib/forms/lifecycle"; + +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 @@ -65,9 +89,14 @@ export const getPostingEndpointById = async (id: string) => { export const deleteEndpoint = authenticatedAction .schema(z.object({ id: z.string() })) .action(async ({ parsedInput: { id }, ctx: { userId } }) => { - await db - .delete(endpoints) - .where(and(eq(endpoints.id, id), eq(endpoints.userId, userId))); + try { + await deleteEndpointForUser({ id, userId }); + } catch (error) { + if (error instanceof AttachedFormExistsError) { + throw new ActionError(error.message); + } + throw error; + } revalidatePath("/endpoints"); }); @@ -83,6 +112,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"); }); @@ -98,6 +128,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"); }); @@ -140,23 +171,49 @@ export const createEndpoint = authenticatedAction export const updateEndpoint = authenticatedAction .schema(updateEndpointFormSchema) .action(async ({ parsedInput, ctx: { userId } }) => { - await db - .update(endpoints) - .set({ - name: parsedInput.name, - schema: parsedInput.schema, - // TODO: add this to form - // enabled: parsedInput.enabled, - formEnabled: parsedInput.formEnabled, - successUrl: parsedInput.successUrl, - failUrl: parsedInput.failUrl, - webhookEnabled: parsedInput.webhookEnabled, - webhook: parsedInput.webhook, - updatedAt: new Date(), - }) - .where( - and(eq(endpoints.id, parsedInput.id), eq(endpoints.userId, userId)) + await db.transaction(async (tx) => { + const [current] = await tx + .select({ + schema: endpoints.schema, + attachedFormId: forms.id, + }) + .from(endpoints) + .leftJoin(forms, eq(forms.endpointId, endpoints.id)) + .where( + and(eq(endpoints.id, parsedInput.id), eq(endpoints.userId, userId)) + ) + .limit(1); + if (!current) throw new ActionError("Endpoint not found."); + + const nextSchema = endpointSchemaForUpdate( + current.schema, + parsedInput.schema, + Boolean(current.attachedFormId) ); + if (!nextSchema) { + throw new ActionError( + "Edit fields in the attached form builder, then publish the form to update this endpoint schema." + ); + } + + await tx + .update(endpoints) + .set({ + name: parsedInput.name, + schema: nextSchema, + // TODO: add this to form + // enabled: parsedInput.enabled, + formEnabled: parsedInput.formEnabled, + successUrl: parsedInput.successUrl, + failUrl: parsedInput.failUrl, + webhookEnabled: parsedInput.webhookEnabled, + webhook: parsedInput.webhook, + updatedAt: new Date(), + }) + .where( + and(eq(endpoints.id, parsedInput.id), eq(endpoints.userId, userId)) + ); + }); revalidatePath("/endpoints"); redirect("/endpoints"); diff --git a/lib/data/forms.ts b/lib/data/forms.ts new file mode 100644 index 0000000..2cba5c9 --- /dev/null +++ b/lib/data/forms.ts @@ -0,0 +1,368 @@ +"use server"; + +import { randomBytes } from "node:crypto"; +import { and, desc, eq, isNull } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { + endpoints, + formOrigins, + forms, + wordpressConnections, +} from "@/lib/db/schema"; +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"; +import { invalidatePublishedForm } from "@/lib/forms/cache"; +import { normalizeOrigin } from "@/lib/forms/origins"; +import { captureServerEvent } from "@/lib/analytics/server"; +import { + FormDraftConflictError, + FormPublicationConflictError, + FormPublicationNotFoundError, + publishFormForUser, + saveFormDraftForUser, +} from "@/lib/forms/publication"; +import { + deleteFormForUser, + FormLifecycleNotFoundError, +} from "@/lib/forms/lifecycle"; + +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().max(120), + definition: formDraftDefinitionV1Schema, +}); + +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."); + 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 }) + .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 } }) => { + try { + const updated = await saveFormDraftForUser({ + ...parsedInput, + userId, + }); + revalidatePath(`/forms/${parsedInput.id}`); + revalidatePath("/forms"); + return updated; + } catch (error) { + if (error instanceof FormDraftConflictError) { + throw new ActionError(error.message); + } + throw error; + } + }); + +export const publishForm = authenticatedAction + .schema(z.object({ id: z.string(), expectedDraftRevision: z.number().int().positive() })) + .action(async ({ parsedInput, ctx: { userId } }) => { + let published; + try { + published = await publishFormForUser({ + ...parsedInput, + userId, + }); + } catch (error) { + if ( + error instanceof FormPublicationConflictError || + error instanceof FormPublicationNotFoundError + ) { + throw new ActionError(error.message); + } + throw error; + } + + 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 } }) => { + let deleted; + try { + deleted = await deleteFormForUser({ id, userId }); + } catch (error) { + if (error instanceof FormLifecycleNotFoundError) { + throw new ActionError(error.message); + } + throw error; + } + 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}`); + }); 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/public-forms.ts b/lib/data/public-forms.ts new file mode 100644 index 0000000..8b530e5 --- /dev/null +++ b/lib/data/public-forms.ts @@ -0,0 +1,71 @@ +import { and, eq, isNotNull } from "drizzle-orm"; +import { unstable_cache } from "next/cache"; +import { db } from "@/lib/db"; +import { endpoints, forms, users } from "@/lib/db/schema"; +import { + formDefinitionV1Schema, + type FormDefinitionV1, +} from "@/lib/forms/definition"; +import { getEntitlement, type RouterPlan } from "@/lib/forms/entitlements"; +import { publishedFormCacheTag } from "@/lib/forms/cache"; + +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/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..db483f5 100644 --- a/lib/data/stripe.ts +++ b/lib/data/stripe.ts @@ -1,50 +1,92 @@ "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"; +import { + isTerminalSubscriptionStatus, + stripeCheckoutMetadata, +} from "@/lib/forms/stripe-subscription-state"; 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 }) + const [account] = await db + .select({ + email: users.email, + stripeCustomerId: users.stripeCustomerId, + stripeSubscriptionId: users.stripeSubscriptionId, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + }) .from(users) .where(eq(users.id, userId)); + if (!account) throw new ActionError("User not found."); + + const stripe = getStripe(); + const returnUrl = `${protocol}://${host}/upgrade`; + if ( + account.stripeCustomerId && + account.stripeSubscriptionId && + !isTerminalSubscriptionStatus(account.stripeSubscriptionStatus) + ) { + const subscription = await stripe.subscriptions.retrieve( + account.stripeSubscriptionId + ); + const item = subscription.items.data[0]; + if (!item || subscription.items.data.length !== 1) { + throw new ActionError( + "This subscription cannot be changed automatically. Contact Router support." + ); + } + const portal = await stripe.billingPortal.sessions.create({ + customer: account.stripeCustomerId, + return_url: returnUrl, + flow_data: { + type: "subscription_update_confirm", + subscription_update_confirm: { + subscription: subscription.id, + items: [{ id: item.id, price: priceId, quantity: item.quantity }], + }, + after_completion: { + type: "redirect", + redirect: { return_url: `${returnUrl}?subscription=updated` }, + }, + }, + }); + redirect(portal.url); + } + const metadata = stripeCheckoutMetadata(userId, parsedInput.plan); const session = await stripe.checkout.sessions.create({ - line_items: [ - { - price: parsedInput.priceId, - quantity: 1, - }, - ], + line_items: [{ price: priceId, quantity: 1 }], mode: "subscription", - customer_email: email, - success_url: `${protocol}://${host}/`, + ...(account.stripeCustomerId + ? { customer: account.stripeCustomerId } + : { customer_email: account.email }), + success_url: `${protocol}://${host}/upgrade?checkout=success`, + cancel_url: `${protocol}://${host}/upgrade`, allow_promotion_codes: true, + metadata, + subscription_data: { + metadata, + }, }); - - 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 +94,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..bc45d59 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,30 @@ 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, + enterpriseMonthlyLeadLimit: users.enterpriseMonthlyLeadLimit, + enterpriseUnlimitedLeads: users.enterpriseUnlimitedLeads, + }) .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..be98584 100644 --- a/lib/data/validations.ts +++ b/lib/data/validations.ts @@ -9,20 +9,81 @@ 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." }), } ); +const endpointConstraintsSchema = z + .object({ + minLength: z.number().int().min(0).max(10_000).optional(), + maxLength: z.number().int().min(1).max(10_000).optional(), + min: z.union([z.number().finite(), z.string().date()]).optional(), + max: z.union([z.number().finite(), z.string().date()]).optional(), + step: z.number().positive().finite().optional(), + allowedValues: z.array(z.string().max(120)).max(100).optional(), + minItems: z.number().int().min(0).max(100).optional(), + maxItems: z.number().int().min(1).max(100).optional(), + mustBeTrue: z.boolean().optional(), + }) + .superRefine((constraints, context) => { + if ( + constraints.minLength !== undefined && + constraints.maxLength !== undefined && + constraints.minLength > constraints.maxLength + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["minLength"], + message: "Minimum length cannot exceed maximum length.", + }); + } + if ( + constraints.min !== undefined && + constraints.max !== undefined && + typeof constraints.min === typeof constraints.max && + constraints.min > constraints.max + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["min"], + message: "Minimum cannot exceed maximum.", + }); + } + if ( + constraints.minItems !== undefined && + constraints.maxItems !== undefined && + constraints.minItems > constraints.maxItems + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["minItems"], + message: "Minimum items cannot exceed maximum items.", + }); + } + }); + +const endpointFieldSchema = z.object({ + key: z.string().min(1, { message: "Please enter a valid field name." }), + value: ValidationType, + required: z.boolean().optional(), + constraints: endpointConstraintsSchema.optional(), +}); + export const createEndpointFormSchema = z.object({ name: z.string().min(1, "Not a valid name."), - schema: z.array( - z.object({ - key: z.string().min(1, { message: "Please enter a valid field name." }), - value: ValidationType, - }) - ), + schema: z.array(endpointFieldSchema), formEnabled: z.boolean(), successUrl: z.string().url().optional(), failUrl: z.string().url().optional(), @@ -33,12 +94,7 @@ export const createEndpointFormSchema = z.object({ export const updateEndpointFormSchema = z.object({ id: z.string(), name: z.string().min(1, "Not a valid name."), - schema: z.array( - z.object({ - key: z.string().min(1, { message: "Please enter a valid field name." }), - value: ValidationType, - }) - ), + schema: z.array(endpointFieldSchema), formEnabled: z.boolean(), successUrl: z.string().url().optional(), failUrl: z.string().url().optional(), 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/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/0013_tiny_giant_girl.sql b/lib/db/drizzle/0013_tiny_giant_girl.sql new file mode 100644 index 0000000..1a3d9b2 --- /dev/null +++ b/lib/db/drizzle/0013_tiny_giant_girl.sql @@ -0,0 +1,5 @@ +ALTER TABLE "user" ADD COLUMN "enterpriseMonthlyLeadLimit" integer;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "enterpriseUnlimitedLeads" boolean DEFAULT false NOT NULL;--> statement-breakpoint +UPDATE "user" +SET "enterpriseMonthlyLeadLimit" = 999999 +WHERE "plan" = 'enterprise' AND "enterpriseMonthlyLeadLimit" IS NULL; diff --git a/lib/db/drizzle/0014_furry_wolfpack.sql b/lib/db/drizzle/0014_furry_wolfpack.sql new file mode 100644 index 0000000..8a558f3 --- /dev/null +++ b/lib/db/drizzle/0014_furry_wolfpack.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" ADD COLUMN "stripeSubscriptionCreatedAt" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "lead_form_created_idx" ON "lead" USING btree ("formId","createdAt"); \ 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/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/0013_snapshot.json b/lib/db/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..c51dcab --- /dev/null +++ b/lib/db/drizzle/meta/0013_snapshot.json @@ -0,0 +1,1293 @@ +{ + "id": "e40f5953-d900-422a-ae40-c4c113741c71", + "prevId": "e5e29dcf-281d-4cc0-a557-382655737666", + "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 + }, + "enterpriseMonthlyLeadLimit": { + "name": "enterpriseMonthlyLeadLimit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enterpriseUnlimitedLeads": { + "name": "enterpriseUnlimitedLeads", + "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/0014_snapshot.json b/lib/db/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..678f413 --- /dev/null +++ b/lib/db/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1321 @@ +{ + "id": "c5f74c6c-7602-4a19-a213-f0e6296ef22d", + "prevId": "e40f5953-d900-422a-ae40-c4c113741c71", + "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": { + "lead_form_created_idx": { + "name": "lead_form_created_idx", + "columns": [ + { + "expression": "formId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "stripeSubscriptionCreatedAt": { + "name": "stripeSubscriptionCreatedAt", + "type": "timestamp with time zone", + "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 + }, + "enterpriseMonthlyLeadLimit": { + "name": "enterpriseMonthlyLeadLimit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enterpriseUnlimitedLeads": { + "name": "enterpriseUnlimitedLeads", + "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..87c194e 100644 --- a/lib/db/drizzle/meta/_journal.json +++ b/lib/db/drizzle/meta/_journal.json @@ -43,6 +43,69 @@ "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 + }, + { + "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 + }, + { + "idx": 13, + "version": "7", + "when": 1788322397797, + "tag": "0013_tiny_giant_girl", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1788390961464, + "tag": "0014_furry_wolfpack", + "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..3b2099a 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,24 @@ 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"), + stripeSubscriptionCreatedAt: timestamp("stripeSubscriptionCreatedAt", { + withTimezone: true, + }), + stripeCurrentPeriodEnd: timestamp("stripeCurrentPeriodEnd", { + withTimezone: true, + }), + stripeCancelAtPeriodEnd: boolean("stripeCancelAtPeriodEnd") + .notNull() + .default(false), + legacyPriceMigrationRequired: boolean("legacyPriceMigrationRequired") + .notNull() + .default(false), + enterpriseMonthlyLeadLimit: integer("enterpriseMonthlyLeadLimit"), + enterpriseUnlimitedLeads: boolean("enterpriseUnlimitedLeads") + .notNull() + .default(false), createdAt: timestamp("createdAt", { withTimezone: true }) .notNull() .defaultNow(), @@ -94,7 +121,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,18 +135,216 @@ export const endpoints = pgTable("endpoint", { updatedAt: timestamp("updatedAt", { mode: "date" }).notNull(), }); -export const leads = pgTable("lead", { - id: text("id") - .$defaultFn(() => createId()) - .notNull() - .primaryKey(), - endpointId: text("endpointId") - .notNull() - .references(() => endpoints.id, { onDelete: "cascade" }), - data: jsonb("data").$type<{ [key: string]: any }>().notNull(), - createdAt: timestamp("createdAt", { mode: "date" }).notNull(), - 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 }), + notifyingAt80: timestamp("notifyingAt80", { withTimezone: true }), + notifyingAt100: timestamp("notifyingAt100", { withTimezone: true }), + notificationLimit80: integer("notificationLimit80"), + notificationLimit100: integer("notificationLimit100"), + 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()) + .notNull() + .primaryKey(), + endpointId: text("endpointId") + .notNull() + .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(), + }, + (lead) => ({ + formCreatedIndex: index("lead_form_created_idx").on( + lead.formId, + lead.createdAt + ), + }) +); + +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", [ diff --git a/lib/forms/cache.ts b/lib/forms/cache.ts new file mode 100644 index 0000000..4ad3281 --- /dev/null +++ b/lib/forms/cache.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..705ebcf --- /dev/null +++ b/lib/forms/definition.ts @@ -0,0 +1,657 @@ +import { z } from "zod"; +import validator from "validator"; +import { + numberSchemaWithConstraints, + stringSchemaWithLength, +} from "./field-constraints"; + +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"), +]); + +type ParsedFormFieldV1 = z.infer; + +function defaultValueIsValid(field: ParsedFormFieldV1): boolean { + if (field.defaultValue === undefined) return true; + + switch (field.kind) { + case "text": + case "textarea": + return stringSchemaWithLength(field.validation).safeParse( + field.defaultValue.trim() + ).success; + case "email": + return stringSchemaWithLength(field.validation) + .email("Not a valid email.") + .safeParse(field.defaultValue.trim()).success; + case "phone": + return stringSchemaWithLength(field.validation) + .refine( + (value) => validator.isMobilePhone(value), + "Not a valid phone number." + ) + .safeParse(field.defaultValue.trim()).success; + case "url": + return stringSchemaWithLength(field.validation) + .url("Not a valid URL.") + .safeParse(field.defaultValue.trim()).success; + case "date": + return ( + (field.validation?.min === undefined || + field.defaultValue >= field.validation.min) && + (field.validation?.max === undefined || + field.defaultValue <= field.validation.max) + ); + case "number": + case "slider": + return numberSchemaWithConstraints( + z.number().finite(), + field.validation + ).safeParse(field.defaultValue).success; + case "select": + case "radio": + return field.options.some( + (option) => option.value === field.defaultValue + ); + case "checkbox-group": { + const allowed = new Set(field.options.map((option) => option.value)); + const uniqueDefaults = new Set(field.defaultValue); + const minimum = Math.max( + field.required ? 1 : 0, + field.validation?.minSelections ?? 0 + ); + const maximum = field.validation?.maxSelections ?? field.options.length; + return ( + uniqueDefaults.size === field.defaultValue.length && + field.defaultValue.every((value) => allowed.has(value)) && + field.defaultValue.length >= minimum && + field.defaultValue.length <= maximum + ); + } + case "checkbox": + case "yes-no": + case "switch": + return true; + } +} + +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) => { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } + }, { + 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); + }); + + if ( + field.kind === "checkbox-group" && + (field.validation?.minSelections ?? 0) > field.options.length + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["fields", index, "validation", "minSelections"], + message: "Minimum selections cannot exceed the number of options.", + }); + } + if ( + field.kind === "checkbox-group" && + (field.validation?.maxSelections ?? 0) > field.options.length + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["fields", index, "validation", "maxSelections"], + message: "Maximum selections cannot exceed the number of options.", + }); + } + } + + if (!defaultValueIsValid(field)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["fields", index, "defaultValue"], + message: "Default value must satisfy the field validation.", + }); + } + }); + }); + +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; + +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; + mustBeTrue?: boolean; + }; +}; + +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.required || field.validation?.minSelections !== undefined + ? { + minItems: field.required + ? Math.max(1, field.validation?.minSelections ?? 0) + : field.validation!.minSelections, + } + : {}), + ...(field.validation?.maxSelections !== undefined + ? { maxItems: field.validation.maxSelections } + : {}), + }, + }; + case "checkbox": + case "switch": + return { + ...base, + value: "boolean", + ...(field.required ? { constraints: { mustBeTrue: true } } : {}), + }; + case "yes-no": + return { ...base, value: "boolean" }; + } + }); +} + +export function hasEndpointSchemaChanged( + draftInput: unknown, + publishedInput: unknown +): boolean { + const draft = formDefinitionV1Schema.safeParse(draftInput); + const published = formDefinitionV1Schema.safeParse(publishedInput); + if (!draft.success || !published.success) return true; + return ( + JSON.stringify(compileEndpointSchema(draft.data)) !== + JSON.stringify(compileEndpointSchema(published.data)) + ); +} + +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": { + 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( + stringSchemaWithLength(field.validation).email("Enter a valid email address."), + field.required + ); + case "phone": + return optionalString( + stringSchemaWithLength(field.validation).refine( + (value) => validator.isMobilePhone(value), + "Enter a valid phone number." + ), + field.required + ); + case "url": + 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) { + 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": { + const schema = numberSchemaWithConstraints( + z.coerce.number().finite("Enter a valid number."), + field.validation + ); + const optionalSchema = z.preprocess( + (value) => (value === "" ? undefined : value), + schema.optional() + ); + return field.required + ? optionalSchema.refine( + (value) => value !== undefined, + "This field is required." + ) + : optionalSchema; + } + 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": + case "switch": + return field.required + ? z.literal(true, { errorMap: () => ({ message: "This field is required." }) }) + : z.boolean().optional(); + case "yes-no": + 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..c967b43 --- /dev/null +++ b/lib/forms/endpoint-schema.ts @@ -0,0 +1,156 @@ +import { z } from "zod"; +import validator from "validator"; +import type { CompiledEndpointField } from "./definition"; +import { + numberSchemaWithConstraints, + stringSchemaWithLength, +} from "./field-constraints"; + +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 function endpointSchemaForUpdate( + current: CompatibleEndpointField[], + requested: CompatibleEndpointField[], + hasAttachedForm: boolean +): CompatibleEndpointField[] | null { + if (!hasAttachedForm) return requested; + const currentShape = current.map(({ key, value }) => ({ key, value })); + const requestedShape = requested.map(({ key, value }) => ({ key, value })); + return JSON.stringify(currentShape) === JSON.stringify(requestedShape) + ? current + : null; +} + +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 = stringSchemaWithLength(constraints).email("Not a valid email."); + break; + case "phone": + schema = stringSchemaWithLength(constraints) + .refine((value) => validator.isMobilePhone(value), "Not a valid phone number."); + break; + case "url": + schema = stringSchemaWithLength(constraints).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": { + schema = numberSchemaWithConstraints(z.number().finite(), { + min: + typeof constraints?.min === "number" + ? constraints.min + : isLegacyField(field) + ? 0 + : undefined, + max: typeof constraints?.max === "number" ? constraints.max : undefined, + step: constraints?.step, + }); + break; + } + case "boolean": + schema = constraints?.mustBeTrue + ? z.literal(true, { + errorMap: () => ({ message: "This field must be accepted." }), + }) + : 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: { + 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), + "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..2659e65 --- /dev/null +++ b/lib/forms/entitlements.ts @@ -0,0 +1,100 @@ +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 type EnterpriseLeadContract = { + monthlyLeadLimit?: number | null; + unlimitedLeads?: boolean; +}; + +export function resolveMonthlyLeadLimit( + plan: RouterPlan, + contract: EnterpriseLeadContract = {} +): number | null { + const fixedLimit = getEntitlement(plan).monthlyLeads; + if (fixedLimit !== null) return fixedLimit; + if (contract.unlimitedLeads === true) return null; + if ( + typeof contract.monthlyLeadLimit === "number" && + Number.isInteger(contract.monthlyLeadLimit) && + contract.monthlyLeadLimit > 0 + ) { + return contract.monthlyLeadLimit; + } + // Enterprise capacity must be configured explicitly. Missing contract data + // fails closed instead of becoming an accidental unlimited entitlement. + return 0; +} + +export function getCapacityState( + plan: RouterPlan, + used: number, + contract: EnterpriseLeadContract = {} +): CapacityState { + const limit = resolveMonthlyLeadLimit(plan, contract); + 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/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/field-identity.ts b/lib/forms/field-identity.ts new file mode 100644 index 0000000..7f29a91 --- /dev/null +++ b/lib/forms/field-identity.ts @@ -0,0 +1,19 @@ +export function normalizeSubmissionKey(value: string): string { + const key = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + return /^[a-z]/.test(key) ? key : `field_${key || "value"}`; +} + +export function allocateSubmissionKey( + label: string, + existingKeys: Iterable +): string { + const base = normalizeSubmissionKey(label); + const used = new Set(existingKeys); + let suffix = 1; + while (used.has(`${base}_${suffix}`)) suffix += 1; + return `${base}_${suffix}`; +} diff --git a/lib/forms/latest-save-queue.ts b/lib/forms/latest-save-queue.ts new file mode 100644 index 0000000..ade519e --- /dev/null +++ b/lib/forms/latest-save-queue.ts @@ -0,0 +1,45 @@ +type LatestSaveQueueOptions = { + getSnapshot: () => T; + fingerprint: (snapshot: T) => string; + getPersistedFingerprint: () => string; + save: (snapshot: T, fingerprint: string) => Promise; +}; + +export function createLatestSaveQueue(options: LatestSaveQueueOptions) { + let active: Promise | null = null; + let queued = false; + + async function drain(): Promise { + while (true) { + queued = false; + const snapshot = options.getSnapshot(); + const fingerprint = options.fingerprint(snapshot); + + if (fingerprint !== options.getPersistedFingerprint()) { + const saved = await options.save(snapshot, fingerprint); + if (!saved) return false; + } + + const latestFingerprint = options.fingerprint(options.getSnapshot()); + if (!queued && latestFingerprint === options.getPersistedFingerprint()) { + return true; + } + } + } + + function persist(): Promise { + if (active) { + queued = true; + return active.then((saved) => (saved ? persist() : false)); + } + + let tracked: Promise; + tracked = drain().finally(() => { + if (active === tracked) active = null; + }); + active = tracked; + return tracked; + } + + return { persist }; +} diff --git a/lib/forms/lead-acceptance.ts b/lib/forms/lead-acceptance.ts new file mode 100644 index 0000000..bddc710 --- /dev/null +++ b/lib/forms/lead-acceptance.ts @@ -0,0 +1,407 @@ +import { and, eq, 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, + resolveMonthlyLeadLimit, + type CapacityState, + type EnterpriseLeadContract, + type RouterPlan, +} from "./entitlements"; +import type { FormPlacement } from "./submission-token"; +import { + crossedUsageThresholds, + deliverUsageThresholdNotification, + 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"; + } +} + +export class LeadStaleRevisionError extends Error { + constructor(readonly currentRevision: number) { + super("The published form changed after this render session was created."); + this.name = "LeadStaleRevisionError"; + } +} + +type HeadlessAcceptanceInput = { + endpointId: string; + values: unknown; + placement: "headless" | "legacy_html"; +}; + +type PublicFormAcceptanceInput = { + publicId: string; + publishedRevision: number; + values: unknown; + placement: FormPlacement; +}; + +export type AcceptLeadInput = HeadlessAcceptanceInput | PublicFormAcceptanceInput; + +type AcceptanceResult = { + leadId: string; + completion?: + | { type: "message"; message: string } + | { type: "redirect"; url: string }; + capacity: CapacityState; +}; + +async function recordWebhookLog( + input: { + endpointId: string; + type: "success" | "error"; + message: Record; + }, + database: typeof db +): Promise { + try { + await database.insert(logs).values({ + endpointId: input.endpointId, + type: input.type, + postType: "webhook", + message: input.message, + createdAt: new Date(), + }); + } catch (error) { + console.error("Could not record webhook delivery:", error); + } +} + +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; +}, database: typeof db): 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 recordWebhookLog({ + endpointId: input.endpointId, + type: "error", + message: { error: message }, + }, database); + return; + } + await recordWebhookLog({ + endpointId: input.endpointId, + type: "success", + message: { success: true, url: input.url }, + }, database); + } catch (error) { + await recordWebhookLog({ + endpointId: input.endpointId, + type: "error", + message: { + error: error instanceof Error ? error.message.slice(0, 2_000) : "Webhook failed.", + }, + }, database); + } +} + +async function logRejectedLead( + input: AcceptLeadInput, + error: unknown, + now: Date, + database: typeof db +): Promise { + try { + let endpointId: string | undefined; + if ("endpointId" in input) { + endpointId = input.endpointId; + } else { + const [row] = await database + .select({ endpointId: forms.endpointId }) + .from(forms) + .where(eq(forms.publicId, input.publicId)) + .limit(1); + endpointId = row?.endpointId; + } + if (!endpointId) return; + await database.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(), + database: typeof db = db +): Promise { + try { + const accepted = await database.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); + if (publicRow.form.publishedRevision !== input.publishedRevision) { + throw new LeadStaleRevisionError(publicRow.form.publishedRevision); + } + 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 enterpriseContract: EnterpriseLeadContract = { + monthlyLeadLimit: row.owner.enterpriseMonthlyLeadLimit, + unlimitedLeads: row.owner.enterpriseUnlimitedLeads, + }; + const monthlyLeadLimit = resolveMonthlyLeadLimit(plan, enterpriseContract); + 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, + notifiedAt80: usagePeriods.notifiedAt80, + notifiedAt100: usagePeriods.notifiedAt100, + }); + + const graceLimit = + monthlyLeadLimit === null + ? null + : Math.round(monthlyLeadLimit * 1.1); + if (graceLimit !== null && usage.leadCount > graceLimit) { + throw new LeadCapacityError( + getCapacityState(plan, usage.leadCount - 1, enterpriseContract) + ); + } + + const usageNotifications: UsageThreshold[] = crossedUsageThresholds({ + used: usage.leadCount, + limit: monthlyLeadLimit, + }).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 + ? { notificationLimit80: monthlyLeadLimit } + : { notificationLimit100: monthlyLeadLimit } + ) + .where( + and( + eq(usagePeriods.userId, row.owner.id), + eq(usagePeriods.periodStart, periodStart), + isNull(notificationLimitColumn) + ) + ); + } + + 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, enterpriseContract), + ownerId: row.owner.id, + ownerEmail: row.owner.email, + formId, + firstPlacement: Boolean(firstPlacement), + periodStart, + usageNotifications, + webhook: + row.endpoint.webhookEnabled && row.endpoint.webhook + ? { endpointId: row.endpoint.id, url: row.endpoint.webhook, values: parsedValues } + : null, + }; + }); + + if (accepted.webhook) { + try { + await deliverWebhook(accepted.webhook, database); + } catch (error) { + console.error("Could not deliver webhook after accepting lead:", error); + } + } + if (accepted.formId && accepted.firstPlacement) { + await captureServerEvent({ + event: "form_first_lead_by_placement", + distinctId: accepted.ownerId, + properties: { + form_id: accepted.formId, + placement: input.placement, + }, + }); + } + for (const threshold of accepted.usageNotifications) { + try { + await deliverUsageThresholdNotification({ + userId: accepted.ownerId, + email: accepted.ownerEmail, + threshold, + periodStart: accepted.periodStart, + now, + }, database); + } 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, database); + throw error; + } +} diff --git a/lib/forms/lifecycle.ts b/lib/forms/lifecycle.ts new file mode 100644 index 0000000..d9cfbc9 --- /dev/null +++ b/lib/forms/lifecycle.ts @@ -0,0 +1,50 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { endpoints, forms } from "@/lib/db/schema"; + +export class AttachedFormExistsError extends Error { + constructor() { + super( + "Remove the attached form before deleting this endpoint. Existing leads are preserved when the form is removed." + ); + this.name = "AttachedFormExistsError"; + } +} + +export class FormLifecycleNotFoundError extends Error { + constructor() { + super("Form not found."); + this.name = "FormLifecycleNotFoundError"; + } +} + +export async function deleteEndpointForUser( + input: { id: string; userId: string }, + database: typeof db = db +): Promise { + const [attachedForm] = await database + .select({ id: forms.id }) + .from(forms) + .innerJoin(endpoints, eq(forms.endpointId, endpoints.id)) + .where( + and(eq(forms.endpointId, input.id), eq(endpoints.userId, input.userId)) + ) + .limit(1); + if (attachedForm) throw new AttachedFormExistsError(); + + await database + .delete(endpoints) + .where(and(eq(endpoints.id, input.id), eq(endpoints.userId, input.userId))); +} + +export async function deleteFormForUser( + input: { id: string; userId: string }, + database: typeof db = db +): Promise<{ publicId: string }> { + const [deleted] = await database + .delete(forms) + .where(and(eq(forms.id, input.id), eq(forms.userId, input.userId))) + .returning({ publicId: forms.publicId }); + if (!deleted) throw new FormLifecycleNotFoundError(); + return deleted; +} diff --git a/lib/forms/origins.ts b/lib/forms/origins.ts new file mode 100644 index 0000000..55a7ce1 --- /dev/null +++ b/lib/forms/origins.ts @@ -0,0 +1,42 @@ +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); +const HOSTED_FORM_HOSTS = new Set(["forms.router.so", ...LOCAL_HOSTS]); + +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 { + const normalized = normalizeOrigin(origin); + return origin === normalized ? normalized : null; + } catch { + return null; + } +} + +export function isHostedFormRequest(request: Request): boolean { + const requestUrl = new URL(request.url); + const origin = requestOrigin(request); + return ( + HOSTED_FORM_HOSTS.has(requestUrl.hostname) && + origin === requestUrl.origin.toLowerCase() + ); +} diff --git a/lib/forms/public-access.ts b/lib/forms/public-access.ts new file mode 100644 index 0000000..e13b57e --- /dev/null +++ b/lib/forms/public-access.ts @@ -0,0 +1,61 @@ +import { and, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { formOrigins, forms } from "@/lib/db/schema"; +import { normalizeOrigin, requestOrigin } 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; +} + +export async function publicFormOptionsResponse( + request: Request, + publicId: string +): Promise { + const normalizedOrigin = requestOrigin(request); + const approved = normalizedOrigin + ? (await isApprovedFormOrigin({ + publicId, + origin: normalizedOrigin, + placement: "embed", + })) || + (await isApprovedFormOrigin({ + publicId, + origin: normalizedOrigin, + placement: "wordpress", + })) + : false; + return new NextResponse(null, { + status: approved ? 204 : 403, + headers: publicCorsHeaders(normalizedOrigin, approved), + }); +} diff --git a/lib/forms/publication.ts b/lib/forms/publication.ts new file mode 100644 index 0000000..0d90b60 --- /dev/null +++ b/lib/forms/publication.ts @@ -0,0 +1,122 @@ +import { and, eq, sql } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { endpoints, forms } from "@/lib/db/schema"; +import { + compileEndpointSchema, + formDraftDefinitionV1Schema, + formDefinitionV1Schema, + type FormDefinitionV1, +} from "./definition"; + +export class FormDraftConflictError extends Error { + constructor() { + super( + "This form changed in another tab. Reload before continuing so you do not overwrite newer work." + ); + this.name = "FormDraftConflictError"; + } +} + +export class FormPublicationConflictError extends Error { + constructor(message = "Save the latest draft before publishing.") { + super(message); + this.name = "FormPublicationConflictError"; + } +} + +export class FormPublicationNotFoundError extends Error { + constructor() { + super("Form not found."); + this.name = "FormPublicationNotFoundError"; + } +} + +export async function saveFormDraftForUser(input: { + id: string; + userId: string; + expectedRevision: number; + name: string; + definition: unknown; +}, database: typeof db = db) { + const definition = formDraftDefinitionV1Schema.parse( + input.definition + ) as FormDefinitionV1; + const [updated] = await database + .update(forms) + .set({ + name: input.name, + draftDefinition: definition, + draftRevision: sql`${forms.draftRevision} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(forms.id, input.id), + eq(forms.userId, input.userId), + eq(forms.draftRevision, input.expectedRevision) + ) + ) + .returning({ revision: forms.draftRevision, updatedAt: forms.updatedAt }); + + if (!updated) throw new FormDraftConflictError(); + return updated; +} + +export async function publishFormForUser(input: { + id: string; + userId: string; + expectedDraftRevision: number; +}, database: typeof db = db) { + return database.transaction(async (tx) => { + const [form] = await tx + .select() + .from(forms) + .where(and(eq(forms.id, input.id), eq(forms.userId, input.userId))) + .limit(1); + if (!form) throw new FormPublicationNotFoundError(); + if (form.draftRevision !== input.expectedDraftRevision) { + throw new FormPublicationConflictError(); + } + + z.string().trim().min(1).max(120).parse(form.name); + 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, input.userId)) + ); + + const [updated] = await tx + .update(forms) + .set({ + publishedDefinition: definition, + publishedRevision: sql`${forms.publishedRevision} + 1`, + publishedAt: now, + unpublishedAt: null, + updatedAt: now, + }) + .where( + and( + eq(forms.id, form.id), + eq(forms.userId, input.userId), + eq(forms.draftRevision, input.expectedDraftRevision), + eq(forms.publishedRevision, form.publishedRevision) + ) + ) + .returning({ + publicId: forms.publicId, + publishedRevision: forms.publishedRevision, + }); + if (!updated) { + throw new FormPublicationConflictError( + "This form changed while it was publishing. Reload and publish the latest draft." + ); + } + return updated; + }); +} 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/request-body.ts b/lib/forms/request-body.ts new file mode 100644 index 0000000..4fa535e --- /dev/null +++ b/lib/forms/request-body.ts @@ -0,0 +1,40 @@ +export class PayloadTooLargeError extends Error { + constructor() { + super("Payload too large."); + this.name = "PayloadTooLargeError"; + } +} + +export async function readLimitedJsonBody( + request: Request, + maxBytes: number +): Promise { + const declaredLength = request.headers.get("content-length"); + if (declaredLength !== null && Number(declaredLength) > maxBytes) { + throw new PayloadTooLargeError(); + } + + const reader = request.body?.getReader(); + if (!reader) return JSON.parse(""); + + const chunks: Uint8Array[] = []; + let receivedBytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedBytes += value.byteLength; + if (receivedBytes > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new PayloadTooLargeError(); + } + chunks.push(value); + } + + const body = new Uint8Array(receivedBytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(body)); +} diff --git a/lib/forms/starters.ts b/lib/forms/starters.ts new file mode 100644 index 0000000..ad1a8dc --- /dev/null +++ b/lib/forms/starters.ts @@ -0,0 +1,310 @@ +import { + compileEndpointSchema, + formDefinitionV1Schema, + type CompiledEndpointField, + type FormDefinitionV1, + type FormFieldV1, +} 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]); +} + +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 && + values.length <= 100 && + new Set(values).size === values.length && + values.every((value) => value.trim().length > 0 && value.length <= 120) + ); +} + +export function isEndpointSchemaCompatible(schema: EndpointSeedField[]): boolean { + if (schema.length > 100) return false; + 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") { + const legacyMinimum = + field.required === undefined && constraints?.min === undefined ? 0 : undefined; + return { + ...base, + kind: "number", + validation: { + ...(typeof constraints?.min === "number" + ? { min: constraints.min } + : legacyMinimum !== undefined + ? { min: legacyMinimum } + : {}), + ...(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: constraints?.mustBeTrue ? "checkbox" : "yes-no", + }; + } + + throw new Error(`Endpoint field ${index + 1} cannot be represented by a Router form.`); +} + +export function seedDefinitionFromEndpoint( + name: string, + schema: EndpointSeedField[] +): FormDefinitionV1 { + 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 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, + } as FormDefinitionV1; +} + +export function hasEndpointSchemaChangedFromEndpoint( + draftInput: unknown, + endpointSchema: EndpointSeedField[] +): boolean { + const draft = formDefinitionV1Schema.safeParse(draftInput); + if (!draft.success || !isEndpointSchemaCompatible(endpointSchema)) return true; + const baseline = seedDefinitionFromEndpoint("Endpoint", endpointSchema); + return ( + JSON.stringify(compileEndpointSchema(draft.data)) !== + JSON.stringify(compileEndpointSchema(baseline)) + ); +} diff --git a/lib/forms/stripe-legacy-migration.ts b/lib/forms/stripe-legacy-migration.ts new file mode 100644 index 0000000..3bed6c3 --- /dev/null +++ b/lib/forms/stripe-legacy-migration.ts @@ -0,0 +1,9 @@ +export function legacyMigrationDecision(input: { + apply: boolean; + cancelAtPeriodEnd: boolean; +}): { updateStripe: boolean; reconcileRouter: boolean } { + return { + updateStripe: input.apply && !input.cancelAtPeriodEnd, + reconcileRouter: input.apply, + }; +} diff --git a/lib/forms/stripe-subscription-state.ts b/lib/forms/stripe-subscription-state.ts new file mode 100644 index 0000000..45d66b8 --- /dev/null +++ b/lib/forms/stripe-subscription-state.ts @@ -0,0 +1,118 @@ +import { + LEGACY_STRIPE_PRICE_TO_PLAN, + planForNewPrice, + type PurchasablePlan, +} from "../constants/stripe"; +import type { RouterPlan } from "./entitlements"; + +export type StripeSubscriptionSnapshot = { + priceId: string; + customerId: string; + subscriptionId: string; + status: string; + createdAt: number; + currentPeriodEnd: number; + cancelAtPeriodEnd: boolean; +}; + +export function isTerminalSubscriptionStatus(status: string | null): boolean { + return status === "canceled" || status === "incomplete_expired"; +} + +export function shouldApplySubscriptionEvent(input: { + storedSubscriptionId: string | null; + storedSubscriptionStatus: string | null; + storedSubscriptionCreatedAt: Date | null; + eventSubscriptionId: string; + eventCreatedAt: Date; +}): boolean { + if (input.storedSubscriptionId === null) return true; + if (input.storedSubscriptionId === input.eventSubscriptionId) { + return !isTerminalSubscriptionStatus(input.storedSubscriptionStatus); + } + if (!isTerminalSubscriptionStatus(input.storedSubscriptionStatus)) { + return false; + } + return ( + input.storedSubscriptionCreatedAt !== null && + input.eventCreatedAt > input.storedSubscriptionCreatedAt + ); +} + +export function shouldClearScheduledCancellation(input: { + priceId: string; + cancelAtPeriodEnd: boolean; + legacyMigrationRequired: boolean; +}): boolean { + return ( + input.legacyMigrationRequired && + input.cancelAtPeriodEnd && + planForNewPrice(input.priceId) !== null + ); +} + +export function invoiceSubscriptionId( + subscription: string | { id: string } | null | undefined +): string | null { + if (typeof subscription === "string") return subscription; + return subscription?.id ?? null; +} + +export function stripeCheckoutMetadata( + userId: string, + plan: PurchasablePlan +): { routerUserId: string; routerPlan: PurchasablePlan } { + return { routerUserId: userId, routerPlan: plan }; +} + +export function subscriptionEntitlementState( + subscription: StripeSubscriptionSnapshot +): { + plan: RouterPlan; + stripeCustomerId: string; + stripeSubscriptionId: string; + stripeSubscriptionStatus: string; + stripeSubscriptionCreatedAt: Date; + 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, + stripeSubscriptionCreatedAt: new Date(subscription.createdAt * 1_000), + stripeCurrentPeriodEnd: new Date(subscription.currentPeriodEnd * 1_000), + stripeCancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + legacyPriceMigrationRequired: Boolean(legacyPlan), + }; +} + +export function endedSubscriptionState( + status: string, + subscriptionId: string, + subscriptionCreatedAt: number +) { + return { + plan: "free" as const, + stripeSubscriptionId: subscriptionId, + stripeSubscriptionStatus: status, + stripeSubscriptionCreatedAt: new Date(subscriptionCreatedAt * 1_000), + 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..dfb8550 --- /dev/null +++ b/lib/forms/submission-token.ts @@ -0,0 +1,116 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +export type FormPlacement = "hosted" | "embed" | "wordpress"; + +type SubmissionTokenInput = { + publicId: string; + revision: number; + 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, + revision: input.revision, + placement: input.placement, + 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 || + !Number.isInteger(payload.revision) || + payload.revision < 1 || + !payload.origin || + !["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; +} + +export function submissionTokenMatchesRequest( + payload: SubmissionTokenPayload, + request: { publicId: string; revision?: number; origin: string | null } +): boolean { + return ( + payload.publicId === request.publicId && + (request.revision === undefined || payload.revision === request.revision) && + request.origin !== null && + payload.origin === request.origin + ); +} diff --git a/lib/forms/usage-notifications.ts b/lib/forms/usage-notifications.ts new file mode 100644 index 0000000..2b4be23 --- /dev/null +++ b/lib/forms/usage-notifications.ts @@ -0,0 +1,207 @@ +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; + +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; + idempotencyKey?: string; +}): Promise { + 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 = + 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."; + + 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; +}, database: typeof db = db): 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 database + .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 database + .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 database + .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/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..154a568 100644 --- a/lib/types.d.ts +++ b/lib/types.d.ts @@ -24,6 +24,17 @@ 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; + mustBeTrue?: boolean; + }; }; /** @@ -40,7 +51,8 @@ type ValidationType = | "date" | "boolean" | "url" - | "zip_code"; + | "zip_code" + | "string_array"; /** * Row type for the main dashboard data on /dashboard route @@ -89,6 +101,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/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. diff --git a/next.config.mjs b/next.config.mjs index 4678774..ad62b98 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,18 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + async headers() { + return [ + { + source: "/embed/v1.js", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=31536000, immutable", + }, + ], + }, + ]; + }, +}; export default nextConfig; diff --git a/package.json b/package.json index 8234867..2da863d 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,18 @@ "dev": "next dev", "docker-dev": "tsx lib/db/migrate.ts &&next dev", "build": "next build", + "check:server-actions": "node scripts/check-server-action-boundaries.mjs", "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", + "test:browser": "playwright test", + "wordpress:check": "integrations/wordpress/check.sh", + "wordpress:test-matrix": "integrations/wordpress/test-matrix.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" }, @@ -88,14 +97,17 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.6", + "@playwright/test": "^1.62.1", "@tailwindcss/typography": "^0.5.16", "@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", "@vitejs/plugin-react": "^5.2.0", + "@wordpress/env": "^11.14.0", "autoprefixer": "^10.4.21", "drizzle-kit": "^0.31.10", "eslint": "^9.39.5", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..46fda7f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; + +const localChromiumExecutable = + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "github" : "list", + use: { + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + ...(localChromiumExecutable + ? { launchOptions: { executablePath: localChromiumExecutable } } + : {}), + }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4783dc3..d26c37e 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) @@ -172,13 +172,13 @@ importers: version: 1.33.0(react@19.2.8) next: specifier: ^15.5.23 - version: 15.5.23(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 15.5.23(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@15.5.23(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 5.0.0-beta.32(next@15.5.23(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) next-safe-action: specifier: ^7.10.8 - version: 7.10.8(next@15.5.23(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@3.25.42) + version: 7.10.8(next@15.5.23(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@3.25.42) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -234,6 +234,9 @@ importers: '@eslint/eslintrc': specifier: ^3.3.6 version: 3.3.6 + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 '@tailwindcss/typography': specifier: ^0.5.16 version: 0.5.16(tailwindcss@3.4.17) @@ -246,6 +249,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 @@ -258,6 +264,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.2.0 version: 5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@1.21.7)(tsx@4.19.4)(yaml@2.9.0)) + '@wordpress/env': + specifier: ^11.14.0 + version: 11.14.0(@types/node@24.13.3)(bufferutil@4.0.9)(utf-8-validate@6.0.5) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.26) @@ -1175,6 +1184,140 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1215,6 +1358,12 @@ packages: '@json2csv/plainjs@7.0.8': resolution: {integrity: sha512-bxsdb7tcHFyq6SGvDdHrxgSLxc8ScNyX+6B1aT+Anp8PEieZIenf6lPIJ2CnB4MQkrNANfFj6mXJlK9f8Bh5wg==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + + '@kwsites/promise-deferred@1.1.1': + resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -1285,6 +1434,9 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1301,16 +1453,207 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@octokit/app@14.1.0': + resolution: {integrity: sha512-g3uEsGOQCBl1+W1rgfwoRFUIR6PtvB2T1E4RpygeUU5LrLvlOqcxrt5lfykIeRpUPpupreGJUYl70fqMDXdTpw==} + engines: {node: '>= 18'} + + '@octokit/auth-app@6.1.4': + resolution: {integrity: sha512-QkXkSOHZK4dA5oUqY5Dk3S+5pN2s1igPjEASNQV8/vgJgW034fQWR16u7VsNOK/EljA00eyjYF5mWNxWKWhHRQ==} + engines: {node: '>= 18'} + + '@octokit/auth-oauth-app@7.1.0': + resolution: {integrity: sha512-w+SyJN/b0l/HEb4EOPRudo7uUOSW51jcK1jwLa+4r7PA8FPFpoxEnHBHMITqCsc/3Vo2qqFjgQfz/xUUvsSQnA==} + engines: {node: '>= 18'} + + '@octokit/auth-oauth-device@6.1.0': + resolution: {integrity: sha512-FNQ7cb8kASufd6Ej4gnJ3f1QB5vJitkoV1O0/g6e6lUsQ7+VsSNRHRmFScN2tV4IgKA12frrr/cegUs0t+0/Lw==} + engines: {node: '>= 18'} + + '@octokit/auth-oauth-user@4.1.0': + resolution: {integrity: sha512-FrEp8mtFuS/BrJyjpur+4GARteUCrPeR/tZJzD8YourzoVhRics7u7we/aDcKv+yywRNwNi/P4fRi631rG/OyQ==} + engines: {node: '>= 18'} + + '@octokit/auth-token@4.0.0': + resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} + engines: {node: '>= 18'} + + '@octokit/auth-unauthenticated@5.0.1': + resolution: {integrity: sha512-oxeWzmBFxWd+XolxKTc4zr+h3mt+yofn4r7OfoIkR/Cj/o70eEGmPsFbueyJE2iBAGpjgTnEOKM3pnuEGVmiqg==} + engines: {node: '>= 18'} + + '@octokit/core@5.2.2': + resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} + engines: {node: '>= 18'} + + '@octokit/endpoint@9.0.6': + resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} + engines: {node: '>= 18'} + + '@octokit/graphql@7.1.1': + resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} + engines: {node: '>= 18'} + + '@octokit/oauth-app@6.1.0': + resolution: {integrity: sha512-nIn/8eUJ/BKUVzxUXd5vpzl1rwaVxMyYbQkNZjHrF7Vk/yu98/YDF/N2KeWO7uZ0g3b5EyiFXFkZI8rJ+DH1/g==} + engines: {node: '>= 18'} + + '@octokit/oauth-authorization-url@6.0.2': + resolution: {integrity: sha512-CdoJukjXXxqLNK4y/VOiVzQVjibqoj/xHgInekviUJV73y/BSIcwvJ/4aNHPBPKcPWFnd4/lO9uqRV65jXhcLA==} + engines: {node: '>= 18'} + + '@octokit/oauth-methods@4.1.0': + resolution: {integrity: sha512-4tuKnCRecJ6CG6gr0XcEXdZtkTDbfbnD5oaHBmLERTjTMZNi2CbfEHZxPU41xXLDG4DfKf+sonu00zvKI9NSbw==} + engines: {node: '>= 18'} + + '@octokit/openapi-types@20.0.0': + resolution: {integrity: sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==} + + '@octokit/openapi-types@24.2.0': + resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + + '@octokit/plugin-paginate-graphql@4.0.1': + resolution: {integrity: sha512-R8ZQNmrIKKpHWC6V2gum4x9LG2qF1RxRjo27gjQcG3j+vf2tLsEfE7I/wRWEPzYMaenr1M+qDAtNcwZve1ce1A==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=5' + + '@octokit/plugin-paginate-rest@9.2.2': + resolution: {integrity: sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-rest-endpoint-methods@10.4.1': + resolution: {integrity: sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-retry@6.1.0': + resolution: {integrity: sha512-WrO3bvq4E1Xh1r2mT9w6SDFg01gFmP81nIG77+p/MqW1JeXXgL++6umim3t6x0Zj5pZm3rXAN+0HEjmmdhIRig==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-throttling@8.2.0': + resolution: {integrity: sha512-nOpWtLayKFpgqmgD0y3GqXafMFuKcA4tRPZIfu7BArd2lEZeb1988nhWhwx4aZWmjDmUfdgVf7W+Tt4AmvRmMQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': ^5.0.0 + + '@octokit/request-error@5.1.1': + resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} + engines: {node: '>= 18'} + + '@octokit/request@8.4.1': + resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} + engines: {node: '>= 18'} + + '@octokit/types@12.6.0': + resolution: {integrity: sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==} + + '@octokit/types@13.10.0': + resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + + '@octokit/webhooks-methods@4.1.0': + resolution: {integrity: sha512-zoQyKw8h9STNPqtm28UGOYFE7O6D4Il8VJwhAtMHFt2C4L0VQT1qGKLeefUOqHNs1mNRYSadVv7x0z8U2yyeWQ==} + engines: {node: '>= 18'} + + '@octokit/webhooks-types@7.6.1': + resolution: {integrity: sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@octokit/webhooks@12.3.2': + resolution: {integrity: sha512-exj1MzVXoP7xnAcAB3jZ97pTvVPkQF9y6GA/dvYC47HV7vLv+24XRS6b/v/XnyikpEuvMhugEXdGtAlU086WkQ==} + engines: {node: '>= 18'} + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} '@paralleldrive/cuid2@2.2.2': resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} + '@php-wasm/cli-util@3.1.52': + resolution: {integrity: sha512-/YP0JqY+l5SjB/kn3yeOe/Kdo88xUkIis3xbhtMivuARTBtxYC7tdHpW1uxJiw6dkmHR89tVFxiPA9PiZdS0/g==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/logger@3.1.52': + resolution: {integrity: sha512-Hr/pKVE4HPjQP4rwvAlZm0DvcEHlVFesHgAvASEc5LgOaKbULy7SVYvtozuuh1e+24Hg1n6CoxA0fKL+nsp+Dg==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/node-5-2@3.1.52': + resolution: {integrity: sha512-z/RzVUr9NPPIe/41VWyJJ5YETEbHwkbq9Jk5fLgc9xhkBDh8EmKRz3MptgvcQyF1IZyuPW4ADe3AZ+R24LrjQw==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/node-7-4@3.1.52': + resolution: {integrity: sha512-e31adpo6SOl7mlptVpHCJwtlDNZWbP/KvPVmkYubs5wtstAzY8YeGzYWndGDUh43SqBr+zxiB9CH2B77IgAyIg==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/node-8-0@3.1.52': + resolution: {integrity: sha512-+Po4d45fSqxgbPPeYRP7XNdc1oCGOWbuxluB0jJthA60LaTtLC4JEQ2B02BhE07gB2W+Nmvvf+HHY3e05JrvQw==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/node-8-1@3.1.52': + resolution: {integrity: sha512-H6wh4NDRTvDanVnumhN5dpSCUsKqBujW/84hHQcyZURGTS36frp1Runr5mmapRF/ZpyH7q+uG1kFzYEYkZIlaQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/node-8-2@3.1.52': + resolution: {integrity: sha512-+PIiU8whNov93e3VnojZik0dDciRkqwd+j2+nm4VDcXfHD9SewHP+k3chaUipTAhDDx8Q6fWYWa1CSGp5jrAtg==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/node-8-3@3.1.52': + resolution: {integrity: sha512-UNkxXw7qUfeS6jG0luztYGosZHZaVMiziaPwY6FFUH0FnHwhrtFpYLI86ksfOZMnS9BjDf7bryIrdr0160tIJQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/node-8-4@3.1.52': + resolution: {integrity: sha512-M7L3lU0co6dPPi3PHs3agG6eeOHAszyJfUq/KmrmRReBJNHyviEcHpaGKextGJArJLfMvAAZjrhGOWVMBrQc/w==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/node-8-5@3.1.52': + resolution: {integrity: sha512-7Y6EkujT5tUTiZKZYVsG8L7cAlLvpl6X5TX+SP1C7YZnxnpZBafX4j/4reQGrYTKdvrK/8NklRH73BQkXhYWhg==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/node@3.1.52': + resolution: {integrity: sha512-5fRw4dMvLGfRKHLZ7iGANVNQ8eGcq8Of91NcKo6qW8TorAIwAhTz+RQHuFtjI2zC7bq21E3IHxLa2I8338yX5A==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/progress@3.1.52': + resolution: {integrity: sha512-Zr4pHQDyX+OhkiLm830x0nt38L6ZRK3cWJMJNYSxvhlN7YKEmTHoA8hifchIE7OfgpF++s0rORXoBvEX5MmteA==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/scopes@3.1.52': + resolution: {integrity: sha512-1+f6czXaQVtO+LT8Bx9gORg/dQ+L5M7Kkp1Bk69RJE9nde6ZO/ex/wLymCx0nORHN01k6csqG2j/Nnew4KLRLg==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/stream-compression@3.1.52': + resolution: {integrity: sha512-RpCCUpF/BwPLbqhUWxHz4utRtu5gSM66SaCFQkxdjAGI5yitaptsu1W6Z7Wp2UDabF3g1qfKfDog0JQ3D9azcw==} + + '@php-wasm/universal@3.1.52': + resolution: {integrity: sha512-vgEV73SfpECzcCuTmr8+tGEzK6syk0p2I6E5CX2kJaE1/prCc4x5DyZtQ0dwhnzcEGrbhKDL2mpxxgl4B9fQOA==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/util@3.1.52': + resolution: {integrity: sha512-lwxtwyIXEO8537eCv5iBfx1WW+DhSYc6obgtTQ6Up85GbHCE050n0RbOWva4XBu7T3Y5PfQshPoPccM/l14chA==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/web-service-worker@3.1.52': + resolution: {integrity: sha512-jEajg31JjVEdLX9yeQ1pa34dtztfMiz6I5y20/LZ3NADO9NrSvcAPsjYf2NkN4cpUDz4mKs6hkOskA6gd1CVxw==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@php-wasm/xdebug-bridge@3.1.52': + resolution: {integrity: sha512-I5yMzjRe+Ivh6N2e5QSh8Fy3VN9WIv7YFhqFOlN3eq0UKCt3gDERCOtt96VMulWYH2m8qmYvpcX20h6p3T2Hyw==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + hasBin: true + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@posthog/browser-common@0.5.0': resolution: {integrity: sha512-8DaxVZS1bQPbA514RePurLNbYjei3P4jhnC206DwVv5XThmZM3QdlsXenI2ujE3pLbgQ79hYn9o1Kda8I3WK/Q==} @@ -2094,6 +2437,16 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@simple-git/args-pathspec@1.0.3': + resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==} + + '@simple-git/argv-parser@1.1.1': + resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -2117,6 +2470,10 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@tabby_ai/hijri-converter@1.0.5': resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} engines: {node: '>=16.0.0'} @@ -2162,6 +2519,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/aws-lambda@8.10.163': + resolution: {integrity: sha512-+4zuoEB3S8RIhimtOFT7zAEk2SbpwrKjjGl9CyYnQR6k08uynxfauIIB0pDU1ssr05F8oyGiETwpn+8eXZMqPw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2174,6 +2534,12 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/btoa-lite@1.0.2': + resolution: {integrity: sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg==} + + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2213,18 +2579,33 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} '@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: @@ -2233,6 +2614,9 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2421,6 +2805,50 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@wolfy1339/lru-cache@11.0.2-patch.1': + resolution: {integrity: sha512-BgYZfL2ADCXKOw2wJtkM3slhHotawWkgIRRxq4wEybnZQPjvAp71SPX35xepMykTw8gXlzWcWPTY31hlbnRsDA==} + engines: {node: 18 >=18.20 || 20 || >=22} + + '@wordpress/env@11.14.0': + resolution: {integrity: sha512-Q/XUhGjFJtlfybw/mq+7H5MsF2u3zWH5YxNR5y473+CeIjZx20wt5Dx0RDHAJcG2ICTky4pJevNlIq/EFnUwIQ==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + hasBin: true + + '@wp-playground/blueprints@3.1.52': + resolution: {integrity: sha512-K4QieiesG8MgZnKQerObS7dON4ngZCDt32aqnYLeaTiSJ2jFXqMcfBXH8MjFO3KVncJsTfU2euh8OtDV1fKkqA==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@wp-playground/cli@3.1.52': + resolution: {integrity: sha512-NXApYcnZwKGOL/E6rdAwHKXbyPFMRIBKF9SX/TlxH9TOgBef0eblwjylnxnYzdAuiOzdJT7PhO4gJw2WSFTZAw==} + hasBin: true + + '@wp-playground/common@3.1.52': + resolution: {integrity: sha512-QGE8CdykgfVEjqRKMXMFsXOwh3MV26FkXJ4c/HzsKKAaRt+zwp7BAK2s6jht40iz9TVDR3ZPcpBrSltfmjIshQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@wp-playground/storage@3.1.52': + resolution: {integrity: sha512-hUQLdZ5iSC8Y01o06aMK/m9DZmMjo2cgl52MvXxR8Cuma+xgrMnlC+G1n7lnjaT+5Wbo58KaawxjZJxUpgcvxw==} + + '@wp-playground/tools@3.1.52': + resolution: {integrity: sha512-rn604x/fH2VFW6rYw3mN5idaMh03sKIbxoeBDPZsh6Ca1Hv9oyheiQFEy0Y3Kzli4B58YwbRUcC0wlv3laT3hw==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@wp-playground/wordpress@3.1.52': + resolution: {integrity: sha512-vXvgrN33y3zvxWxVjtEEb4L4ZNfgBRPlvVe6wXHB19Dbiu32ZLkrlUpEkpKvft7wnrXnWvbDJqbEBfU2rKJtqw==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + '@zip.js/zip.js@2.7.57': + resolution: {integrity: sha512-BtonQ1/jDnGiMed6OkV6rZYW78gLmLswkHOzyMrMb+CAR7CZO8phOHO6c2qw6qb1g1betN7kwEHhhZk30dv+NA==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=16.5.0'} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2431,13 +2859,24 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.6.0: + resolution: {integrity: sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==} + engines: {node: '>=14.0'} + agent-base@7.1.3: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2446,6 +2885,10 @@ packages: resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} engines: {node: '>=12'} + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -2465,9 +2908,15 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2486,6 +2935,9 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + array-includes@3.1.8: resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} @@ -2525,6 +2977,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + autoprefixer@10.4.21: resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} engines: {node: ^10 || ^12 || >=14} @@ -2547,10 +3002,23 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + before-after-hook@2.2.3: + resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + body-parser@1.20.6: + resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bottleneck@2.19.5: + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -2566,13 +3034,34 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + btoa-lite@1.0.0: + resolution: {integrity: sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bufferutil@4.0.9: resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} engines: {node: '>=6.14.2'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2600,10 +3089,21 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -2611,9 +3111,39 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clean-git-ref@2.0.1: + resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2624,10 +3154,16 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -2638,16 +3174,39 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.0.2: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + copy-dir@1.3.0: + resolution: {integrity: sha512-Q4+qBFnN4bwGwvtXXzbp4P/4iNk0MaiGAzvQ8OiMtlLjkIKjmNN689uVzShSM0908q7GoFHXIPx4zi75ocoaHw==} + core-js@3.50.0: resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2739,6 +3298,14 @@ packages: date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2762,6 +3329,10 @@ packages: decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2769,6 +3340,13 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -2777,10 +3355,21 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + deprecation@2.3.1: + resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2791,9 +3380,16 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff3@0.0.3: + resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + docker-compose@0.24.8: + resolution: {integrity: sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==} + engines: {node: '>= 6.0.0'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -2923,6 +3519,12 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.161: resolution: {integrity: sha512-hwtetwfKNZo/UlwHIVBlKZVdy7o8bIZxxKs0Mv/ROPiQQQmDgdm5a+KvKtBsxM8ZjFzTaCeLoodZ8jiBE3o9rA==} @@ -2945,6 +3547,13 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -3007,6 +3616,13 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -3115,6 +3731,11 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.6.0: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} @@ -3134,13 +3755,29 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3165,8 +3802,18 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + + fast-xml-builder@1.3.1: + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==} + + fast-xml-parser@5.11.1: + resolution: {integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==} + hasBin: true + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} fdir@6.4.5: resolution: {integrity: sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==} @@ -3196,6 +3843,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3215,9 +3866,30 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-ext-extra-prebuilt@2.2.7: + resolution: {integrity: sha512-Q7rayYRBDIvDF01HWOwSSjoaP+05N1g+o3BXL1Zf8Frw2JkjSmi4EtvCBITuW30l6hB2m2TW1pehdh8wyU/+gw==} + engines: {node: '>= 8.0.0'} + + fs-extra@11.1.1: + resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} + engines: {node: '>=14.14'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3237,6 +3909,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3249,6 +3925,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -3281,6 +3961,13 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -3288,6 +3975,10 @@ packages: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3325,18 +4016,40 @@ packages: htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3353,6 +4066,17 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@4.1.2: + resolution: {integrity: sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + input-otp@1.4.2: resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} peerDependencies: @@ -3367,6 +4091,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -3426,6 +4154,10 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -3469,6 +4201,9 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-unsafe@2.0.2: + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -3487,6 +4222,11 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-git@1.37.6: + resolution: {integrity: sha512-qr1NFCPsVTZ6YGqTXw0CzamnsHyH9QQ1OTEfeXIweSljRUMzuHFCJdUn0wc6OcjtTDns6knxjPb7N6LmJeftOA==} + engines: {node: '>=14.17'} + hasBin: true + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -3504,6 +4244,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} + hasBin: true + js-yaml@4.3.1: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true @@ -3528,6 +4272,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3540,10 +4287,26 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3575,19 +4338,45 @@ packages: lodash.castarray@4.4.0: resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@3.0.0: + resolution: {integrity: sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==} + engines: {node: '>=8'} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -3610,14 +4399,50 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -3628,16 +4453,32 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minimisted@2.0.1: + resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3651,6 +4492,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + next-auth@5.0.0-beta.32: resolution: {integrity: sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==} peerDependencies: @@ -3730,6 +4575,10 @@ packages: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} engines: {node: '>=0.10.0'} + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + nwsapi@2.2.20: resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} @@ -3779,14 +4628,37 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + octokit@3.1.2: + resolution: {integrity: sha512-MG5qmrTL5y8KYwFgE1A4JWmgfQBaIETE/lOlfwNYx1QOtCQHGVxkRJmdUJltFc1HVn73d61TlMhMyNTOtMl+ng==} + engines: {node: '>= 18'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@4.1.1: + resolution: {integrity: sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==} + engines: {node: '>=8'} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3798,6 +4670,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3808,10 +4683,18 @@ packages: parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3823,6 +4706,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3848,9 +4734,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==} @@ -3889,10 +4772,24 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4012,9 +4909,20 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4029,6 +4937,18 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + react-day-picker@9.14.0: resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} engines: {node: '>=18'} @@ -4119,6 +5039,10 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -4141,6 +5065,14 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resend@6.20.0: resolution: {integrity: sha512-fXDFt7jVMuka6ruS79DzaQFKPyxOAUtoYUGSl3dTUyOnFK9klmUULxADQRzFFtHfHRipYyy62jYcm4cMKqvtjw==} engines: {node: '>=20'} @@ -4150,6 +5082,9 @@ packages: '@react-email/render': optional: true + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4166,10 +5101,21 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rollup@4.62.4: resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4185,6 +5131,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -4196,6 +5145,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -4220,6 +5173,14 @@ packages: engines: {node: '>=10'} hasBin: true + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -4232,6 +5193,14 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + sharp@0.35.3: resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} engines: {node: '>=20.9.0'} @@ -4276,10 +5245,22 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-git@3.36.0: + resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} + sonner@1.7.4: resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} peerDependencies: @@ -4301,6 +5282,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -4310,6 +5294,10 @@ packages: standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -4348,6 +5336,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -4368,6 +5359,9 @@ packages: resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} engines: {node: '>=12.*'} + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -4386,6 +5380,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -4446,10 +5444,25 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -4487,6 +5500,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4515,6 +5532,20 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + universal-github-app-jwt@1.2.0: + resolution: {integrity: sha512-dncpMpnsKBk0eetwfN8D8OUHGfiDhhJ+mtsbMl+7PfW7mYjiH8LIcqRmYMtzYLgSh47HjfdBtrBwIQ/gizKR3g==} + + universal-user-agent@6.0.1: + resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unrs-resolver@1.7.8: resolution: {integrity: sha512-2zsXwyOXmCX9nGz4vhtZRYhe30V78heAv+KDc21A/KMdovGHbZcixeD5JHEF0DrFXzdytwuzYclcPbvp8A3Jlw==} @@ -4559,10 +5590,18 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + validator@13.15.35: resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} engines: {node: '>= 0.10'} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vaul@1.1.2: resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} peerDependencies: @@ -4657,6 +5696,12 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + wasm-feature-detect@1.8.0: + resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-vitals@5.3.0: resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==} @@ -4710,6 +5755,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4718,6 +5767,9 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -4734,6 +5786,18 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -4741,6 +5805,10 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4749,13 +5817,32 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + zod@3.25.42: resolution: {integrity: sha512-PcALTLskaucbeHc41tU/xfjfhcz8z0GdhhDcSgrCTmSazUuqnYqiXO63M0QUBVwpBlsLsNVn5qHSC5Dw3KZvaQ==} + zstddec@0.2.0: + resolution: {integrity: sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -5395,6 +6482,131 @@ snapshots: '@img/sharp-win32-x64@0.35.3': optional: true + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@24.13.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.13.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/confirm@5.1.21(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/core@10.3.2(@types/node@24.13.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.13.3) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/editor@4.2.23(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/external-editor': 1.0.3(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/expand@4.0.23(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/external-editor@1.0.3(@types/node@24.13.3)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/number@3.0.23(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/password@4.0.23(@types/node@24.13.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/prompts@7.10.1(@types/node@24.13.3)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.13.3) + '@inquirer/confirm': 5.1.21(@types/node@24.13.3) + '@inquirer/editor': 4.2.23(@types/node@24.13.3) + '@inquirer/expand': 4.0.23(@types/node@24.13.3) + '@inquirer/input': 4.3.1(@types/node@24.13.3) + '@inquirer/number': 3.0.23(@types/node@24.13.3) + '@inquirer/password': 4.0.23(@types/node@24.13.3) + '@inquirer/rawlist': 4.1.11(@types/node@24.13.3) + '@inquirer/search': 3.2.2(@types/node@24.13.3) + '@inquirer/select': 4.4.2(@types/node@24.13.3) + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/rawlist@4.1.11(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/type': 3.0.10(@types/node@24.13.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/search@3.2.2(@types/node@24.13.3)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.13.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/select@4.4.2(@types/node@24.13.3)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.13.3) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.13.3) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.13.3 + + '@inquirer/type@3.0.10(@types/node@24.13.3)': + optionalDependencies: + '@types/node': 24.13.3 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -5445,6 +6657,14 @@ snapshots: '@json2csv/formatters': 7.0.8 '@streamparser/json': 0.0.23 + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + '@kwsites/promise-deferred@1.1.1': {} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -5491,6 +6711,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@nodable/entities@3.0.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5505,25 +6727,294 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@panva/hkdf@1.2.1': {} + '@octokit/app@14.1.0': + dependencies: + '@octokit/auth-app': 6.1.4 + '@octokit/auth-unauthenticated': 5.0.1 + '@octokit/core': 5.2.2 + '@octokit/oauth-app': 6.1.0 + '@octokit/plugin-paginate-rest': 9.2.2(@octokit/core@5.2.2) + '@octokit/types': 12.6.0 + '@octokit/webhooks': 12.3.2 - '@paralleldrive/cuid2@2.2.2': + '@octokit/auth-app@6.1.4': dependencies: - '@noble/hashes': 1.8.0 + '@octokit/auth-oauth-app': 7.1.0 + '@octokit/auth-oauth-user': 4.1.0 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + deprecation: 2.3.1 + lru-cache: '@wolfy1339/lru-cache@11.0.2-patch.1' + universal-github-app-jwt: 1.2.0 + universal-user-agent: 6.0.1 - '@pkgjs/parseargs@0.11.0': - optional: true + '@octokit/auth-oauth-app@7.1.0': + dependencies: + '@octokit/auth-oauth-device': 6.1.0 + '@octokit/auth-oauth-user': 4.1.0 + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + '@types/btoa-lite': 1.0.2 + btoa-lite: 1.0.0 + universal-user-agent: 6.0.1 - '@posthog/browser-common@0.5.0': + '@octokit/auth-oauth-device@6.1.0': dependencies: - '@posthog/core': 1.48.5 - '@posthog/types': 1.405.0 + '@octokit/oauth-methods': 4.1.0 + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 - '@posthog/core@1.48.5': + '@octokit/auth-oauth-user@4.1.0': dependencies: - '@posthog/types': 1.405.0 + '@octokit/auth-oauth-device': 6.1.0 + '@octokit/oauth-methods': 4.1.0 + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + btoa-lite: 1.0.0 + universal-user-agent: 6.0.1 - '@posthog/types@1.405.0': {} + '@octokit/auth-token@4.0.0': {} + + '@octokit/auth-unauthenticated@5.0.1': + dependencies: + '@octokit/request-error': 5.1.1 + '@octokit/types': 12.6.0 + + '@octokit/core@5.2.2': + dependencies: + '@octokit/auth-token': 4.0.0 + '@octokit/graphql': 7.1.1 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + before-after-hook: 2.2.3 + universal-user-agent: 6.0.1 + + '@octokit/endpoint@9.0.6': + dependencies: + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/graphql@7.1.1': + dependencies: + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/oauth-app@6.1.0': + dependencies: + '@octokit/auth-oauth-app': 7.1.0 + '@octokit/auth-oauth-user': 4.1.0 + '@octokit/auth-unauthenticated': 5.0.1 + '@octokit/core': 5.2.2 + '@octokit/oauth-authorization-url': 6.0.2 + '@octokit/oauth-methods': 4.1.0 + '@types/aws-lambda': 8.10.163 + universal-user-agent: 6.0.1 + + '@octokit/oauth-authorization-url@6.0.2': {} + + '@octokit/oauth-methods@4.1.0': + dependencies: + '@octokit/oauth-authorization-url': 6.0.2 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + btoa-lite: 1.0.0 + + '@octokit/openapi-types@20.0.0': {} + + '@octokit/openapi-types@24.2.0': {} + + '@octokit/plugin-paginate-graphql@4.0.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + + '@octokit/plugin-paginate-rest@9.2.2(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 12.6.0 + + '@octokit/plugin-rest-endpoint-methods@10.4.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 12.6.0 + + '@octokit/plugin-retry@6.1.0(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + bottleneck: 2.19.5 + + '@octokit/plugin-throttling@8.2.0(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 12.6.0 + bottleneck: 2.19.5 + + '@octokit/request-error@5.1.1': + dependencies: + '@octokit/types': 13.10.0 + deprecation: 2.3.1 + once: 1.4.0 + + '@octokit/request@8.4.1': + dependencies: + '@octokit/endpoint': 9.0.6 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/types@12.6.0': + dependencies: + '@octokit/openapi-types': 20.0.0 + + '@octokit/types@13.10.0': + dependencies: + '@octokit/openapi-types': 24.2.0 + + '@octokit/webhooks-methods@4.1.0': {} + + '@octokit/webhooks-types@7.6.1': {} + + '@octokit/webhooks@12.3.2': + dependencies: + '@octokit/request-error': 5.1.1 + '@octokit/webhooks-methods': 4.1.0 + '@octokit/webhooks-types': 7.6.1 + aggregate-error: 3.1.0 + + '@panva/hkdf@1.2.1': {} + + '@paralleldrive/cuid2@2.2.2': + dependencies: + '@noble/hashes': 1.8.0 + + '@php-wasm/cli-util@3.1.52': + dependencies: + '@php-wasm/util': 3.1.52 + fast-xml-parser: 5.11.1 + jsonc-parser: 3.3.1 + + '@php-wasm/logger@3.1.52': {} + + '@php-wasm/node-5-2@3.1.52': + dependencies: + '@php-wasm/universal': 3.1.52 + wasm-feature-detect: 1.8.0 + + '@php-wasm/node-7-4@3.1.52': + dependencies: + '@php-wasm/universal': 3.1.52 + wasm-feature-detect: 1.8.0 + + '@php-wasm/node-8-0@3.1.52': + dependencies: + '@php-wasm/universal': 3.1.52 + wasm-feature-detect: 1.8.0 + + '@php-wasm/node-8-1@3.1.52': + dependencies: + '@php-wasm/universal': 3.1.52 + wasm-feature-detect: 1.8.0 + + '@php-wasm/node-8-2@3.1.52': + dependencies: + '@php-wasm/universal': 3.1.52 + wasm-feature-detect: 1.8.0 + + '@php-wasm/node-8-3@3.1.52': + dependencies: + '@php-wasm/universal': 3.1.52 + wasm-feature-detect: 1.8.0 + + '@php-wasm/node-8-4@3.1.52': + dependencies: + '@php-wasm/universal': 3.1.52 + wasm-feature-detect: 1.8.0 + + '@php-wasm/node-8-5@3.1.52': + dependencies: + '@php-wasm/universal': 3.1.52 + wasm-feature-detect: 1.8.0 + + '@php-wasm/node@3.1.52(bufferutil@4.0.9)(utf-8-validate@6.0.5)': + dependencies: + '@php-wasm/cli-util': 3.1.52 + '@php-wasm/logger': 3.1.52 + '@php-wasm/node-5-2': 3.1.52 + '@php-wasm/node-7-4': 3.1.52 + '@php-wasm/node-8-0': 3.1.52 + '@php-wasm/node-8-1': 3.1.52 + '@php-wasm/node-8-2': 3.1.52 + '@php-wasm/node-8-3': 3.1.52 + '@php-wasm/node-8-4': 3.1.52 + '@php-wasm/node-8-5': 3.1.52 + '@php-wasm/universal': 3.1.52 + '@php-wasm/util': 3.1.52 + fs-ext-extra-prebuilt: 2.2.7 + wasm-feature-detect: 1.8.0 + ws: 8.21.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@php-wasm/progress@3.1.52': + dependencies: + '@php-wasm/logger': 3.1.52 + + '@php-wasm/scopes@3.1.52': {} + + '@php-wasm/stream-compression@3.1.52': + dependencies: + '@php-wasm/util': 3.1.52 + + '@php-wasm/universal@3.1.52': + dependencies: + '@php-wasm/logger': 3.1.52 + '@php-wasm/progress': 3.1.52 + '@php-wasm/stream-compression': 3.1.52 + '@php-wasm/util': 3.1.52 + ini: 4.1.2 + + '@php-wasm/util@3.1.52': {} + + '@php-wasm/web-service-worker@3.1.52': + dependencies: + '@php-wasm/scopes': 3.1.52 + '@php-wasm/universal': 3.1.52 + + '@php-wasm/xdebug-bridge@3.1.52(bufferutil@4.0.9)(utf-8-validate@6.0.5)': + dependencies: + '@php-wasm/logger': 3.1.52 + '@php-wasm/universal': 3.1.52 + ws: 8.21.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + xml2js: 0.6.2 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@posthog/browser-common@0.5.0': + dependencies: + '@posthog/core': 1.48.5 + '@posthog/types': 1.405.0 + + '@posthog/core@1.48.5': + dependencies: + '@posthog/types': 1.405.0 + + '@posthog/types@1.405.0': {} '@radix-ui/number@1.1.1': {} @@ -6297,6 +7788,14 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 + '@simple-git/args-pathspec@1.0.3': {} + + '@simple-git/argv-parser@1.1.1': + dependencies: + '@simple-git/args-pathspec': 1.0.3 + + '@sindresorhus/is@4.6.0': {} + '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -6316,6 +7815,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@tabby_ai/hijri-converter@1.0.5': {} '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17)': @@ -6362,6 +7865,8 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/aws-lambda@8.10.163': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.27.4 @@ -6383,6 +7888,15 @@ snapshots: dependencies: '@babel/types': 7.27.3 + '@types/btoa-lite@1.0.2': {} + + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 24.13.3 + '@types/responselike': 1.0.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -6418,10 +7932,23 @@ snapshots: '@types/estree@1.0.9': {} + '@types/http-cache-semantics@4.2.0': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.13.3 + + '@types/keyv@3.1.4': + dependencies: + '@types/node': 24.13.3 + + '@types/ms@2.1.0': {} + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 @@ -6429,9 +7956,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 @@ -6440,6 +7973,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/responselike@1.0.3': + dependencies: + '@types/node': 24.13.3 + '@types/trusted-types@2.0.7': optional: true @@ -6651,14 +8188,121 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + '@wolfy1339/lru-cache@11.0.2-patch.1': {} + + '@wordpress/env@11.14.0(@types/node@24.13.3)(bufferutil@4.0.9)(utf-8-validate@6.0.5)': + dependencies: + '@inquirer/prompts': 7.10.1(@types/node@24.13.3) + '@wp-playground/cli': 3.1.52(bufferutil@4.0.9)(utf-8-validate@6.0.5) + adm-zip: 0.6.0 + chalk: 4.1.2 + copy-dir: 1.3.0 + cross-spawn: 7.0.6 + docker-compose: 0.24.8 + got: 11.8.6 + js-yaml: 3.15.2 + ora: 4.1.1 + rimraf: 5.0.10 + simple-git: 3.36.0 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - supports-color + - utf-8-validate + + '@wp-playground/blueprints@3.1.52': + dependencies: + '@php-wasm/logger': 3.1.52 + '@php-wasm/progress': 3.1.52 + '@php-wasm/stream-compression': 3.1.52 + '@php-wasm/universal': 3.1.52 + '@php-wasm/util': 3.1.52 + '@php-wasm/web-service-worker': 3.1.52 + '@wp-playground/common': 3.1.52 + '@wp-playground/storage': 3.1.52 + '@wp-playground/wordpress': 3.1.52 + ajv: 8.18.0 + + '@wp-playground/cli@3.1.52(bufferutil@4.0.9)(utf-8-validate@6.0.5)': + dependencies: + '@php-wasm/cli-util': 3.1.52 + '@php-wasm/logger': 3.1.52 + '@php-wasm/node': 3.1.52(bufferutil@4.0.9)(utf-8-validate@6.0.5) + '@php-wasm/progress': 3.1.52 + '@php-wasm/universal': 3.1.52 + '@php-wasm/util': 3.1.52 + '@php-wasm/xdebug-bridge': 3.1.52(bufferutil@4.0.9)(utf-8-validate@6.0.5) + '@wp-playground/blueprints': 3.1.52 + '@wp-playground/common': 3.1.52 + '@wp-playground/storage': 3.1.52 + '@wp-playground/tools': 3.1.52 + '@wp-playground/wordpress': 3.1.52 + express: 4.22.2 + fs-extra: 11.1.1 + tmp-promise: 3.0.3 + wasm-feature-detect: 1.8.0 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@wp-playground/common@3.1.52': + dependencies: + '@php-wasm/universal': 3.1.52 + '@php-wasm/util': 3.1.52 + + '@wp-playground/storage@3.1.52': + dependencies: + '@php-wasm/stream-compression': 3.1.52 + '@php-wasm/universal': 3.1.52 + '@php-wasm/util': 3.1.52 + '@zip.js/zip.js': 2.7.57 + isomorphic-git: 1.37.6 + octokit: 3.1.2 + pako: 1.0.11 + sha.js: 2.4.12 + + '@wp-playground/tools@3.1.52': + dependencies: + '@php-wasm/util': 3.1.52 + '@wp-playground/blueprints': 3.1.52 + + '@wp-playground/wordpress@3.1.52': + dependencies: + '@php-wasm/logger': 3.1.52 + '@php-wasm/universal': 3.1.52 + '@php-wasm/util': 3.1.52 + '@wp-playground/common': 3.1.52 + zstddec: 0.2.0 + + '@zip.js/zip.js@2.7.57': {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 acorn@8.18.0: {} + adm-zip@0.6.0: {} + agent-base@7.1.3: {} + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -6666,10 +8310,21 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -6685,8 +8340,14 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + anynum@1.0.1: {} + arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -6704,6 +8365,8 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + array-flatten@1.1.1: {} + array-includes@3.1.8: dependencies: call-bind: 1.0.8 @@ -6770,6 +8433,8 @@ snapshots: async-function@1.0.0: {} + async-lock@1.4.1: {} + autoprefixer@10.4.21(postcss@8.5.26): dependencies: browserslist: 4.25.0 @@ -6790,8 +8455,31 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + + before-after-hook@2.2.3: {} + binary-extensions@2.3.0: {} + body-parser@1.20.6: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bottleneck@2.19.5: {} + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -6812,12 +8500,35 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.0) + btoa-lite@1.0.0: {} + + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bufferutil@4.0.9: dependencies: node-gyp-build: 4.8.4 + bytes@3.1.2: {} + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6843,11 +8554,24 @@ snapshots: chai@6.2.2: {} + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + chardet@2.2.0: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -6864,8 +8588,32 @@ snapshots: dependencies: clsx: 2.1.1 + clean-git-ref@2.0.1: {} + + clean-stack@2.2.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + clone@1.0.4: {} + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): @@ -6880,22 +8628,42 @@ snapshots: - '@types/react' - '@types/react-dom' + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-name@1.1.3: {} + color-name@1.1.4: {} commander@4.1.1: {} concat-map@0.0.1: {} + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + cookie@1.0.2: {} + copy-dir@1.3.0: {} + core-js@3.50.0: {} + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6982,6 +8750,10 @@ snapshots: date-fns@4.4.0: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@3.2.7: dependencies: ms: 2.1.3 @@ -6994,10 +8766,20 @@ snapshots: decimal.js@10.5.0: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-is@0.1.4: {} deepmerge@4.3.1: {} + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + defer-to-connect@2.0.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -7010,8 +8792,14 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + depd@2.0.0: {} + + deprecation@2.3.1: {} + dequal@2.0.3: {} + destroy@1.2.0: {} + detect-libc@2.1.2: optional: true @@ -7019,8 +8807,14 @@ snapshots: didyoumean@1.2.2: {} + diff3@0.0.3: {} + dlv@1.1.3: {} + docker-compose@0.24.8: + dependencies: + yaml: 2.9.0 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -7061,9 +8855,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 @@ -7075,6 +8869,12 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + electron-to-chromium@1.5.161: {} embla-carousel-react@8.6.0(react@19.2.8): @@ -7093,6 +8893,12 @@ snapshots: emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@4.5.0: {} entities@6.0.0: {} @@ -7288,6 +9094,10 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} eslint-config-next@15.5.23(eslint@9.39.5(jiti@1.21.7))(typescript@5.8.3): @@ -7476,6 +9286,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.6.0: dependencies: estraverse: 5.3.0 @@ -7492,10 +9304,52 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + events@3.3.0: {} + expect-type@1.4.0: {} + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.6 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} fast-equals@5.2.2: {} @@ -7522,6 +9376,22 @@ snapshots: fast-sha256@1.3.0: {} + fast-uri@3.1.6: {} + + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.11.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -7544,6 +9414,18 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -7565,8 +9447,25 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forwarded@0.2.0: {} + fraction.js@4.3.7: {} + fresh@0.5.2: {} + + fs-ext-extra-prebuilt@2.2.7: + dependencies: + nan: 2.28.0 + + fs-extra@11.1.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7585,6 +9484,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -7605,6 +9506,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -7641,10 +9546,28 @@ snapshots: gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} has-bigints@1.1.0: {} + has-flag@3.0.0: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -7686,6 +9609,16 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + http-cache-semantics@4.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 @@ -7693,6 +9626,11 @@ snapshots: transitivePeerDependencies: - supports-color + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 @@ -7700,10 +9638,20 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.4: {} @@ -7715,6 +9663,12 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + + inherits@2.0.4: {} + + ini@4.1.2: {} + input-otp@1.4.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -7728,6 +9682,8 @@ snapshots: internmap@2.0.3: {} + ipaddr.js@1.9.1: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -7795,6 +9751,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-interactive@1.0.0: {} + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -7836,6 +9794,8 @@ snapshots: dependencies: which-typed-array: 1.1.19 + is-unsafe@2.0.2: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -7851,6 +9811,20 @@ snapshots: isexe@2.0.0: {} + isomorphic-git@1.37.6: + dependencies: + async-lock: 1.4.1 + clean-git-ref: 2.0.1 + crc-32: 1.2.2 + diff3: 0.0.3 + ignore: 5.3.2 + minimisted: 2.0.1 + pako: 1.0.11 + pify: 4.0.1 + readable-stream: 4.7.0 + sha.js: 2.4.12 + simple-get: 4.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -7872,6 +9846,11 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@3.15.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -7909,6 +9888,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -7917,6 +9898,27 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.8 @@ -7924,6 +9926,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7951,16 +9964,34 @@ snapshots: lodash.castarray@4.4.0: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + lodash.isplainobject@4.0.6: {} + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.18.1: {} + log-symbols@3.0.0: + dependencies: + chalk: 2.4.2 + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 + lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -7979,13 +10010,33 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mimic-fn@2.1.0: {} + + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + minimatch@3.1.5: dependencies: brace-expansion: 1.1.18 @@ -7996,31 +10047,45 @@ snapshots: minimist@1.2.8: {} + minimisted@2.0.1: + dependencies: + minimist: 1.2.8 + minipass@7.1.2: {} + ms@2.0.0: {} + ms@2.1.3: {} + mute-stream@0.0.8: {} + + mute-stream@2.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 + nan@2.28.0: {} + nanoid@3.3.18: {} napi-postinstall@0.2.4: {} natural-compare@1.4.0: {} - next-auth@5.0.0-beta.32(next@15.5.23(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + negotiator@0.6.3: {} + + next-auth@5.0.0-beta.32(next@15.5.23(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@auth/core': 0.41.3 - next: 15.5.23(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 15.5.23(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 - next-safe-action@7.10.8(next@15.5.23(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@3.25.42): + next-safe-action@7.10.8(next@15.5.23(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@3.25.42): dependencies: - next: 15.5.23(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 15.5.23(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: @@ -8031,7 +10096,7 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - next@15.5.23(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@15.5.23(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 15.5.23 '@swc/helpers': 0.5.15 @@ -8049,6 +10114,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.23 '@next/swc-win32-arm64-msvc': 15.5.23 '@next/swc-win32-x64-msvc': 15.5.23 + '@playwright/test': 1.62.1 sharp: 0.35.3(@types/node@24.13.3) transitivePeerDependencies: - '@babel/core' @@ -8063,6 +10129,8 @@ snapshots: normalize-range@0.1.2: {} + normalize-url@6.1.0: {} + nwsapi@2.2.20: {} oauth4webapi@3.8.7: {} @@ -8115,6 +10183,31 @@ snapshots: obug@2.1.4: {} + octokit@3.1.2: + dependencies: + '@octokit/app': 14.1.0 + '@octokit/core': 5.2.2 + '@octokit/oauth-app': 6.1.0 + '@octokit/plugin-paginate-graphql': 4.0.1(@octokit/core@5.2.2) + '@octokit/plugin-paginate-rest': 9.2.2(@octokit/core@5.2.2) + '@octokit/plugin-rest-endpoint-methods': 10.4.1(@octokit/core@5.2.2) + '@octokit/plugin-retry': 6.1.0(@octokit/core@5.2.2) + '@octokit/plugin-throttling': 8.2.0(@octokit/core@5.2.2) + '@octokit/request-error': 5.1.1 + '@octokit/types': 12.6.0 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8124,12 +10217,25 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@4.1.1: + dependencies: + chalk: 3.0.0 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + log-symbols: 3.0.0 + mute-stream: 0.0.8 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-cancelable@2.1.1: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -8140,6 +10246,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -8153,8 +10261,12 @@ snapshots: leac: 0.6.0 peberminta: 0.9.0 + parseurl@1.3.3: {} + path-exists@4.0.0: {} + path-expression-matcher@1.6.2: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -8164,6 +10276,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@0.1.13: {} + pathe@2.0.3: {} peberminta@0.9.0: {} @@ -8181,8 +10295,6 @@ snapshots: dependencies: pg: 8.23.0 - pg-protocol@1.10.0: {} - pg-protocol@1.16.0: {} pg-types@2.2.0: @@ -8225,8 +10337,18 @@ snapshots: pify@2.3.0: {} + pify@4.0.1: {} + pirates@4.0.7: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postal-mime@2.7.5: {} @@ -8330,12 +10452,24 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + process@0.11.10: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} qs@6.15.3: @@ -8347,6 +10481,17 @@ snapshots: queue-microtask@1.2.3: {} + quick-lru@5.1.1: {} + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + react-day-picker@9.14.0(react@19.2.8): dependencies: '@date-fns/tz': 1.5.0 @@ -8431,6 +10576,14 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -8472,6 +10625,10 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + resend@6.20.0(@react-email/render@2.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)): dependencies: postal-mime: 2.7.5 @@ -8479,6 +10636,8 @@ snapshots: optionalDependencies: '@react-email/render': 2.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + resolve-alpn@1.2.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -8495,8 +10654,21 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + reusify@1.1.0: {} + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + rollup@4.62.4: dependencies: '@types/estree': 1.0.9 @@ -8543,6 +10715,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -8556,6 +10730,8 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.6.1: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -8570,8 +10746,34 @@ snapshots: semver@7.7.2: {} - semver@7.8.5: - optional: true + semver@7.8.5: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color set-function-length@1.2.2: dependencies: @@ -8595,6 +10797,14 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + sharp@0.35.3(@types/node@24.13.3): dependencies: '@img/colour': 1.1.0 @@ -8678,8 +10888,28 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-git@3.36.0: + dependencies: + '@kwsites/file-exists': 1.1.1 + '@kwsites/promise-deferred': 1.1.1 + '@simple-git/args-pathspec': 1.0.3 + '@simple-git/argv-parser': 1.1.1 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + sonner@1.7.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -8696,6 +10926,8 @@ snapshots: split2@4.2.0: {} + sprintf-js@1.0.3: {} + stable-hash@0.0.5: {} stackback@0.0.2: {} @@ -8705,6 +10937,8 @@ snapshots: '@stablelib/base64': 1.0.1 fast-sha256: 1.3.0 + statuses@2.0.2: {} + std-env@4.2.0: {} stop-iteration-iterator@1.1.0: @@ -8774,6 +11008,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -8791,6 +11029,10 @@ snapshots: '@types/node': 24.13.3 qs: 6.15.3 + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8): dependencies: client-only: 0.0.1 @@ -8808,6 +11050,10 @@ snapshots: pirates: 4.0.7 ts-interface-checker: 0.1.13 + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -8881,10 +11127,24 @@ snapshots: dependencies: tldts-core: 6.1.86 + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.7 + + tmp@0.2.7: {} + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -8925,6 +11185,11 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -8969,6 +11234,17 @@ snapshots: undici-types@7.18.2: {} + universal-github-app-jwt@1.2.0: + dependencies: + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + universal-user-agent@6.0.1: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + unrs-resolver@1.7.8: dependencies: napi-postinstall: 0.2.4 @@ -9027,8 +11303,12 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + validator@13.15.35: {} + vary@1.1.2: {} + vaul@1.1.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -9102,6 +11382,12 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + wasm-feature-detect@1.8.0: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + web-vitals@5.3.0: {} web-vitals@6.0.0: {} @@ -9171,6 +11457,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -9183,6 +11475,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + wrappy@1.0.2: {} + ws@8.21.3(bufferutil@4.0.9)(utf-8-validate@6.0.5): optionalDependencies: bufferutil: 4.0.9 @@ -9190,14 +11484,51 @@ snapshots: xml-name-validator@5.0.0: {} + xml-naming@0.3.0: {} + + xml2js@0.6.2: + dependencies: + sax: 1.6.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} xtend@4.0.2: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yaml@2.9.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.3: {} + zod@3.25.42: {} + + zstddec@0.2.0: {} diff --git a/public/downloads/router-forms.zip b/public/downloads/router-forms.zip new file mode 100644 index 0000000..866132e Binary files /dev/null and b/public/downloads/router-forms.zip differ diff --git a/public/embed/v1.js b/public/embed/v1.js new file mode 100644 index 0000000..bd456e3 --- /dev/null +++ b/public/embed/v1.js @@ -0,0 +1,404 @@ +(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"; + var mountSequence = 0; + + function installStyles(ownerDocument) { + var styleDocument = ownerDocument || document; + if (styleDocument.getElementById(styleId)) return; + var style = styleDocument.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(""); + styleDocument.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, instanceId, fieldId) { + return "router-form-" + publicId + "-" + instanceId + "-" + 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, 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, 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)")); + 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 && field.kind !== "checkbox-group" && index === 0) input.required = true; + var defaults = field.defaultValue === undefined + ? [] + : 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(); + 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); + range.dataset.routerTouched = String(field.required || field.defaultValue !== undefined); + var rangeValue = element("output", "router-form-v1__range-value", range.value); + rangeValue.htmlFor = id; + range.addEventListener("input", function () { + range.dataset.routerTouched = "true"; + 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") { + if (controls && controls.value !== "") values[field.key] = Number(controls.value); + } else if (field.kind === "slider") { + if (controls && controls.dataset.routerTouched === "true") { + 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, form.dataset.instanceId, 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(target.ownerDocument); + 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"); + 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.dataset.instanceId = instanceId; + form.noValidate = false; + var fields = element("div", "router-form-v1__fields"); + 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"); + var honeypotLabel = element("label", "", "Leave this field empty"); + var honeypotInput = document.createElement("input"); + honeypotInput.name = "_router_form_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 if (result.error === "stale_form_revision") throw new Error("This form changed while you were filling it out. Refresh the page and try again."); + 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; + } + var completion = element("p", "router-form-v1__status", result.completion && result.completion.message ? result.completion.message : "Thanks — your response has been received."); + completion.setAttribute("role", "status"); + completion.setAttribute("aria-live", "polite"); + completion.tabIndex = -1; + root.replaceChildren(completion); + completion.focus(); + } 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(); + if (payload.revision !== session.revision) { + var currentResponse = await fetch( + apiBase + "/api/public/forms/" + encodeURIComponent(publicId) + "?revision=" + encodeURIComponent(session.revision), + { cache: "no-store" } + ); + if (!currentResponse.ok) throw new Error("This form is unavailable."); + payload = await currentResponse.json(); + if (payload.revision !== session.revision) throw new Error("This form is updating. Refresh the page and try again."); + } + 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/check-server-action-boundaries.mjs b/scripts/check-server-action-boundaries.mjs new file mode 100644 index 0000000..e9b0131 --- /dev/null +++ b/scripts/check-server-action-boundaries.mjs @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; + +const manifestPath = + process.argv[2] ?? ".next/server/server-reference-manifest.json"; +const manifest = JSON.parse(await readFile(manifestPath, "utf8")); +const references = [ + ...Object.values(manifest.node ?? {}), + ...Object.values(manifest.edge ?? {}), +]; +const forbiddenReaders = new Set([ + "getPublishedForm", + "getUserPublishedFormIds", +]); +const exposed = references.filter( + (reference) => + forbiddenReaders.has(reference.exportedName) +); + +if (exposed.length > 0) { + console.error( + `Internal form readers were registered as Server Actions: ${exposed + .map((reference) => reference.exportedName) + .join(", ")}` + ); + process.exitCode = 1; +} else { + console.log("Internal form readers are absent from the Server Action manifest."); +} diff --git a/scripts/stripe-legacy-migration.ts b/scripts/stripe-legacy-migration.ts new file mode 100644 index 0000000..4d01704 --- /dev/null +++ b/scripts/stripe-legacy-migration.ts @@ -0,0 +1,146 @@ +import { LEGACY_STRIPE_PRICE_TO_PLAN } from "../lib/constants/stripe"; +import { db } from "../lib/db"; +import { users } from "../lib/db/schema"; +import { + shouldApplySubscriptionEvent, + subscriptionEntitlementState, +} from "../lib/forms/stripe-subscription-state"; +import { legacyMigrationDecision } from "../lib/forms/stripe-legacy-migration"; +import { getStripe } from "../lib/utils/stripe-client"; +import { and, eq, isNull } from "drizzle-orm"; + +async function main() { + const apply = process.argv.includes("--apply"); + const stripe = getStripe(); + let inspected = 0; + let alreadyScheduled = 0; + let changed = 0; + let reconciled = 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; + let currentSubscription = subscription; + const decision = legacyMigrationDecision({ + apply, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + }); + if (currentSubscription.cancel_at_period_end) { + alreadyScheduled += 1; + } + if (decision.updateStripe) { + currentSubscription = await stripe.subscriptions.update(subscription.id, { + cancel_at_period_end: true, + }); + changed += 1; + } + + if (decision.reconcileRouter) { + const userCondition = currentSubscription.metadata.routerUserId + ? eq(users.id, currentSubscription.metadata.routerUserId) + : eq(users.stripeCustomerId, currentSubscription.customer as string); + const [owner] = await db + .select({ + id: users.id, + stripeSubscriptionId: users.stripeSubscriptionId, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + stripeSubscriptionCreatedAt: users.stripeSubscriptionCreatedAt, + }) + .from(users) + .where(userCondition) + .limit(1); + if (!owner) { + throw new Error( + `Could not find Router user for subscription ${currentSubscription.id}.` + ); + } + if ( + !shouldApplySubscriptionEvent({ + storedSubscriptionId: owner.stripeSubscriptionId, + storedSubscriptionStatus: owner.stripeSubscriptionStatus, + storedSubscriptionCreatedAt: owner.stripeSubscriptionCreatedAt, + eventSubscriptionId: currentSubscription.id, + eventCreatedAt: new Date(currentSubscription.created * 1_000), + }) + ) { + console.log( + `${currentSubscription.id}\tsuperseded subscription ignored\t${price}` + ); + continue; + } + const [updatedUser] = await db + .update(users) + .set( + subscriptionEntitlementState({ + priceId: currentSubscription.items.data[0]?.price.id ?? price, + customerId: currentSubscription.customer as string, + subscriptionId: currentSubscription.id, + status: currentSubscription.status, + createdAt: currentSubscription.created, + currentPeriodEnd: currentSubscription.current_period_end, + cancelAtPeriodEnd: currentSubscription.cancel_at_period_end, + }) + ) + .where( + and( + eq(users.id, owner.id), + owner.stripeSubscriptionId === null + ? isNull(users.stripeSubscriptionId) + : eq(users.stripeSubscriptionId, owner.stripeSubscriptionId), + owner.stripeSubscriptionStatus === null + ? isNull(users.stripeSubscriptionStatus) + : eq(users.stripeSubscriptionStatus, owner.stripeSubscriptionStatus), + owner.stripeSubscriptionCreatedAt === null + ? isNull(users.stripeSubscriptionCreatedAt) + : eq( + users.stripeSubscriptionCreatedAt, + owner.stripeSubscriptionCreatedAt + ) + ) + ) + .returning({ id: users.id }); + if (!updatedUser) { + throw new Error( + `Router user changed while reconciling subscription ${currentSubscription.id}.` + ); + } + reconciled += 1; + console.log( + `${currentSubscription.id}\t${subscription.cancel_at_period_end ? "already scheduled and reconciled" : "scheduled and reconciled"}\t${price}` + ); + } else { + console.log( + `${subscription.id}\t${subscription.cancel_at_period_end ? "already scheduled" : "would schedule"}\t${price}` + ); + } + } + } + + console.log( + JSON.stringify({ + mode: apply ? "apply" : "dry-run", + inspected, + alreadyScheduled, + changed, + reconciled, + }) + ); + + 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; +}); diff --git a/scripts/test-forward-migrations.sh b/scripts/test-forward-migrations.sh new file mode 100755 index 0000000..aa7ed80 --- /dev/null +++ b/scripts/test-forward-migrations.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +set -eu + +if [ -z "${PGURL:-}" ]; then + echo "PGURL is required." >&2 + exit 1 +fi + +for migration in lib/db/drizzle/*.sql; do + case "$migration" in + *0013_*) + psql "$PGURL" -v ON_ERROR_STOP=1 -c \ + "INSERT INTO \"user\" (id, email, plan) VALUES ('migration-enterprise', 'migration-enterprise@example.com', 'enterprise');" + ;; + esac + psql "$PGURL" -v ON_ERROR_STOP=1 -f "$migration" +done + +backfilled_limit=$(psql "$PGURL" -v ON_ERROR_STOP=1 -Atc \ + "SELECT \"enterpriseMonthlyLeadLimit\" FROM \"user\" WHERE id = 'migration-enterprise';") +if [ "$backfilled_limit" != "999999" ]; then + echo "Enterprise compatibility allowance was not backfilled." >&2 + exit 1 +fi 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 * * * *" } ] } diff --git a/vitest.config.ts b/vitest.config.ts index ef6ac31..2f81f8e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,16 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "@": fileURLToPath(new URL(".", import.meta.url)), + }, + }, test: { environment: "jsdom", + include: ["__tests__/**/*.test.{ts,tsx}"], }, });