mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-30 00:15:48 +00:00
Merge pull request #287 from warmbly/feature/public-forms
feat: forms v2 with themes, layouts, pages, personalized links, funnel analytics, campaign integration and a custom forms domain
This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
service:
|
||||
description: "Service to build (all, backend, consumer, worker, tracking, realtime)"
|
||||
description: "Service to build (all, backend, consumer, worker, forms, tracking, realtime)"
|
||||
required: false
|
||||
default: "all"
|
||||
|
||||
@@ -54,6 +54,13 @@ jobs:
|
||||
- 'internal/**'
|
||||
- 'cmd/worker/**'
|
||||
- 'deploy/docker/worker.Dockerfile'
|
||||
forms:
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'internal/**'
|
||||
- 'cmd/forms/**'
|
||||
- 'forms/**'
|
||||
- 'deploy/docker/forms.Dockerfile'
|
||||
tracking:
|
||||
- 'tracking/**'
|
||||
realtime:
|
||||
@@ -67,6 +74,7 @@ jobs:
|
||||
BACKEND: ${{ steps.filter.outputs.backend }}
|
||||
CONSUMER: ${{ steps.filter.outputs.consumer }}
|
||||
WORKER: ${{ steps.filter.outputs.worker }}
|
||||
FORMS: ${{ steps.filter.outputs.forms }}
|
||||
TRACKING: ${{ steps.filter.outputs.tracking }}
|
||||
REALTIME: ${{ steps.filter.outputs.realtime }}
|
||||
run: |
|
||||
@@ -74,7 +82,7 @@ jobs:
|
||||
native=""
|
||||
if [ "$EVENT" = "workflow_dispatch" ]; then
|
||||
sel="${SELECTED:-all}"
|
||||
for s in backend consumer worker; do
|
||||
for s in backend consumer worker forms; do
|
||||
if [ "$sel" = "all" ] || [ "$sel" = "$s" ]; then go="$go\"$s\","; fi
|
||||
done
|
||||
for s in tracking realtime; do
|
||||
@@ -84,6 +92,7 @@ jobs:
|
||||
if [ "$BACKEND" = "true" ]; then go="$go\"backend\","; fi
|
||||
if [ "$CONSUMER" = "true" ]; then go="$go\"consumer\","; fi
|
||||
if [ "$WORKER" = "true" ]; then go="$go\"worker\","; fi
|
||||
if [ "$FORMS" = "true" ]; then go="$go\"forms\","; fi
|
||||
if [ "$TRACKING" = "true" ]; then native="$native\"tracking\","; fi
|
||||
if [ "$REALTIME" = "true" ]; then native="$native\"realtime\","; fi
|
||||
fi
|
||||
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
web: ${{ steps.filter.outputs.web }}
|
||||
admin: ${{ steps.filter.outputs.admin }}
|
||||
site: ${{ steps.filter.outputs.site }}
|
||||
forms: ${{ steps.filter.outputs.forms }}
|
||||
make: ${{ steps.filter.outputs.make }}
|
||||
ios: ${{ steps.filter.outputs.ios }}
|
||||
steps:
|
||||
@@ -58,6 +59,13 @@ jobs:
|
||||
- 'admin/**'
|
||||
site:
|
||||
- 'site/**'
|
||||
forms:
|
||||
- 'forms/**'
|
||||
# The mirrored design engine has a copy in web/; editing only
|
||||
# that side must still run the drift check below.
|
||||
- 'web/src/components/app/forms/designCore.ts'
|
||||
- 'web/src/components/app/forms/form-theme.css'
|
||||
- 'scripts/check-forms-mirror.sh'
|
||||
make:
|
||||
- 'integrations/make/**'
|
||||
ios:
|
||||
@@ -193,6 +201,34 @@ jobs:
|
||||
# in package.json today.
|
||||
run: pnpm build
|
||||
|
||||
forms-ci:
|
||||
name: Forms CI
|
||||
needs: changes
|
||||
if: needs.changes.outputs.forms == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: forms
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check forms mirror
|
||||
working-directory: .
|
||||
run: ./scripts/check-forms-mirror.sh
|
||||
|
||||
- uses: ./.github/actions/setup-pnpm
|
||||
with:
|
||||
working-directory: forms
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
make-ci:
|
||||
name: Make App CI
|
||||
needs: changes
|
||||
@@ -362,7 +398,7 @@ jobs:
|
||||
ci-status:
|
||||
name: CI Status
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, migrations-ci, go-ci, web-ci, admin-ci, site-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images]
|
||||
needs: [changes, migrations-ci, go-ci, web-ci, admin-ci, site-ci, forms-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check CI status
|
||||
|
||||
@@ -105,11 +105,12 @@ Event codec: `CODEC_PROVIDER=json` is required wherever workers are exercised (t
|
||||
|
||||
Infra runs in docker; the Go services and frontends run natively on the host for fast iteration — no docker image rebuilds when you change app code. Targets live in the `Makefile`.
|
||||
|
||||
- `make dev` — the one-command stack: brings up the docker infra and waits for postgres, applies migrations, loads seed fixtures (skip with `SEED=false`), installs web + admin deps on first run, starts realtime and tracking as containers, then runs backend + consumer + worker + dashboard + admin in one terminal. Login: dev@warmbly.com / password123, with the emailed login code in Mailpit at http://localhost:18025. Ctrl-C stops the app; infra stays up.
|
||||
- `make dev` — the one-command stack: brings up the docker infra and waits for postgres, applies migrations, loads seed fixtures (skip with `SEED=false`), installs web + admin deps on first run, starts realtime and tracking as containers, then runs backend + forms + consumer + worker + dashboard + admin in one terminal. Login: dev@warmbly.com / password123, with the emailed login code in Mailpit at http://localhost:18025. Ctrl-C stops the app; infra stays up.
|
||||
- `make infra` — start the backing services in docker (postgres, redis, nats, mailpit). Run once; leave running. Kafka, Schema Registry, localstack, cloud-tasks, and stripe-mock are gone; the stack is no-cloud by default (NATS, local KMS, filesystem blobs, in-process tasks).
|
||||
- `make backend` — run the API natively on `:8080` (applies the embedded migrations on boot against the docker postgres).
|
||||
- `make consumer` / `make worker` / `make worker-premium` — run those Go services natively, each in its own terminal. Two native workers exist because tier placement is strict: free-trial orgs place onto the free-tier worker (`make worker`), paid orgs onto the premium one (`make worker-premium`). The workers read encrypted DEKs through the backend's `/internal/dek` endpoint (the prod `http` provider, no worker DB), so `make backend` must be running and their `INTERNAL_API_TOKEN` must match (the targets are pre-wired to match).
|
||||
- `make run` — backend + consumer + both workers together in one terminal (Ctrl-C stops all).
|
||||
- `make run` — backend + forms + consumer + worker together in one terminal (Ctrl-C stops all).
|
||||
- `make forms` — the public forms service natively on `:8090` (`cmd/forms`): builds the `forms/` TanStack app, then serves it plus the embed loader and public submissions. No database; it resolves forms and forwards submissions through the backend's internal API, so `make backend` must be running and `INTERNAL_API_TOKEN` must match (pre-wired). The backend's `FORMS_DOMAIN=localhost:8090` makes dashboard share links point at it; `make forms FORMS_PORT=8091` (matched on `make backend`) moves it when worktrees share the machine. `make forms-web` runs the Vite dev server (:5175) for iterating on the app itself.
|
||||
- `make sandbox` — fully working demo environment: seeds the "Sunrise Labs" showcase org (live mailboxes: SMTP -> mailpit, IMAP -> dovecot, credentials sealed with `CREDENTIALS_ENCRYPTION_KEY`) and runs the simulator that plays the internet (delivers mail into dovecot inboxes, opens pixels, clicks tracked links, replies as contacts). Needs `make run` + `make tracking` alongside. Docs: `docs/content/docs/development/sandbox.mdx`.
|
||||
- `make tracking` / `make realtime` — the Rust tracking pixel service (:3000) and Elixir/Phoenix websocket fanout (:4000). Deliberately kept out of `make run`; start them only when needed, and only if you have the cargo / elixir toolchains on the host.
|
||||
- `make web` / `make admin` / `make site` — frontend dev servers (5173 / 5174 / 4321), pointed at the native backend.
|
||||
@@ -150,6 +151,7 @@ Everything in the dashboard must use our own theme, not browser/library defaults
|
||||
- Interaction details are part of "done". Before calling a dashboard change finished, walk the small things a user hits in the first minute, because these are what make the product feel broken even when the data flow is right:
|
||||
- every dropdown / popover / picker closes on click-away and on Escape, including when it sits inside a dialog or drawer. Dialog cards stop `mousedown` propagation so the backdrop does not close them; React's `stopPropagation` also stops the native event, so any click-outside listener must be registered in the **capture** phase (`document.addEventListener("mousedown", fn, true)`, as `PopoverMenu` and `useClickOutside` do), never the bubble phase. Escape must close only the innermost layer: the dialog's Escape handler bails out while a `[data-floating]` popover or the `[role="alertdialog"]` confirm is on screen
|
||||
- toggles are the shared `Toggle` (sky pill, 32x18) from `campaigns/preferences/components/CampaignPreferenceBoolBox`; never hand-roll a switch. If a whole row toggles on click, the switch itself must `stopPropagation` so it does not toggle twice, and a `<label htmlFor>` pointing at the switch would double-fire too, so use a plain element for the row title
|
||||
- **a page is not shipped until it is routed, linked and titled.** Three lists have to agree, and nothing fails the build when they do not: the route table in `web/src/main.tsx`, the nav that links to it (`AppNav`, `settings/layout.tsx`), and the title map in `web/src/hooks/useDocumentTitle.ts` (static pathnames in `ROUTE_TITLES`, `:id` routes as a regex in `PARAM_ROUTES`). A nav entry with no route renders nothing; a page component with no route is dead code nobody can reach; a route with no title falls through to the literal `"Page not found | Warmbly"` in the tab, which reads as a broken app on a page that works. All three drift silently, so check them together, and after a merge that touched routing check the settings nav against `main.tsx` specifically
|
||||
- every detail page reachable from a list has a way back on all viewports: a "← Section" link above the title (see `campaigns/[id]/layout.tsx`) or a back arrow in its header (see `AutomationFlow`); the header breadcrumb is desktop-only and its crumbs must stay clickable, so it does not count as the only route back
|
||||
- multi-step flows animate between steps (directional slide via `AnimatePresence`, see `NewCampaignDialog`), explain why a step cannot be left instead of only disabling the button, refuse to skip ahead past an incomplete step, and confirm before discarding a dirty draft
|
||||
- a control that does nothing is worse than no control: never ship a checkbox or button whose action cannot succeed (for example "launch after create" when start requires contacts). Remove it or wire it to something that works
|
||||
@@ -195,6 +197,8 @@ API keys with the `REALTIME_SUBSCRIBE` permission (bit 11) can connect to the sa
|
||||
- `cmd/backend`: API and business orchestration
|
||||
- `cmd/consumer`: consumes Kafka events and updates platform state
|
||||
- `cmd/worker`: execution worker for send/sync operations
|
||||
- `cmd/forms`: the public face of hosted lead-capture forms (`internal/formserver`): serves the built `forms/` app, per-form page shells (with the per-form embed CSP), the embed loader and public submissions on their own origin (`FORMS_DOMAIN`). No database; the backend's internal API is its only dependency, like the tracking service
|
||||
- `forms/`: the public form page app (React + TanStack Router/Query/Form, Vite CSR build). Renders a published form from the same-origin `/api/forms/:publicID`, submits to `/api/forms/:publicID/submit`; the Go forms service hosts the build
|
||||
- `tracking/`: open and click tracking service
|
||||
- `realtime/`: websocket fanout service
|
||||
- `web/`: in-product frontend (dashboard). Customer-facing only: it holds no platform-admin screens, and operator tooling must not be added back here
|
||||
|
||||
@@ -25,7 +25,7 @@ PROTO_GEN_FILES := $(PROTO_DIR)/tasks.pb.go
|
||||
.PHONY: poollink-dev poollink-dev-down poollink-dev-reset setup-tools fmt lint check-migrations proto check-proto \
|
||||
up claim doctor cli seed-demo seed seed-plan sandbox sandbox-seed sandbox-simulate reset logs status stop down test-seed \
|
||||
restart restart-go restart-all infra infra-down app app-down app-logs \
|
||||
backend consumer worker run dev tracking realtime web \
|
||||
backend forms forms-web consumer worker run dev tracking realtime web \
|
||||
admin site docs grant-admin revoke-admin gen-key db-reset db-wipe migrate
|
||||
|
||||
setup-tools:
|
||||
@@ -40,6 +40,7 @@ fmt:
|
||||
gofmt -w ./cmd ./internal
|
||||
|
||||
lint: check-migrations
|
||||
./scripts/check-forms-mirror.sh
|
||||
$(GO_BIN)/golangci-lint run --timeout=5m
|
||||
|
||||
# Duplicate migration versions only collide once two PRs are both on main, and
|
||||
@@ -558,6 +559,7 @@ backend:
|
||||
$(GO_DEV_ENV) \
|
||||
$(AI_DEV_ENV) \
|
||||
API_HOST=0.0.0.0:8080 \
|
||||
FORMS_DOMAIN=localhost:$(FORMS_PORT) \
|
||||
GIN_MODE=debug \
|
||||
APP_URL=http://$(WEB_HOST):5173 \
|
||||
CORS_ALLOW_ORIGINS=$(CORS_ORIGINS) \
|
||||
@@ -575,6 +577,30 @@ backend:
|
||||
INTERNAL_API_TOKEN=local-dev-internal-token \
|
||||
go run ./cmd/backend
|
||||
|
||||
# Public forms service on :8090 (cmd/forms): serves the TanStack form app
|
||||
# (forms/), per-form page shells, the embed loader and public submissions.
|
||||
# Builds the app first (fast), then runs the Go service; it resolves forms
|
||||
# and forwards submissions through the backend's internal API, so
|
||||
# `make backend` must be running and INTERNAL_API_TOKEN must match
|
||||
# (pre-wired here, like the workers).
|
||||
# Override the port (here AND on make backend, so share links agree) with
|
||||
# `make forms FORMS_PORT=8091` when worktrees share the machine.
|
||||
FORMS_PORT ?= 8090
|
||||
forms:
|
||||
@if [ ! -d forms/node_modules ]; then echo "Installing forms dependencies (first run)..."; cd forms && pnpm install; fi
|
||||
@cd forms && pnpm build --logLevel warn
|
||||
GIN_MODE=debug \
|
||||
FORMS_PORT=$(FORMS_PORT) \
|
||||
FORMS_STATIC_DIR=forms/dist \
|
||||
BACKEND_INTERNAL_URL=http://localhost:8080 \
|
||||
INTERNAL_API_TOKEN=local-dev-internal-token \
|
||||
go run ./cmd/forms
|
||||
|
||||
# Vite dev server for the forms app (:5175, /api proxied to the Go service)
|
||||
# when you want hot reload while editing forms/ itself.
|
||||
forms-web:
|
||||
cd forms && FORMS_API_URL=http://localhost:$(FORMS_PORT) pnpm dev
|
||||
|
||||
# Event consumer (NATS by default; Kafka with -tags kafka) -> postgres.
|
||||
consumer:
|
||||
$(GO_DEV_ENV) \
|
||||
@@ -602,9 +628,10 @@ worker:
|
||||
# Workers are interchangeable now; run a second `make worker WORKER_ID=<uuid>`
|
||||
# in another terminal to add parallelism.
|
||||
run:
|
||||
@echo "backend + consumer + worker (native). Ctrl-C stops all. Run 'make infra' first if infra is down."
|
||||
@echo "backend + forms + consumer + worker (native). Ctrl-C stops all. Run 'make infra' first if infra is down."
|
||||
@trap 'kill 0' INT TERM; \
|
||||
$(MAKE) --no-print-directory backend & \
|
||||
$(MAKE) --no-print-directory forms & \
|
||||
$(MAKE) --no-print-directory consumer & \
|
||||
$(MAKE) --no-print-directory worker & \
|
||||
wait
|
||||
@@ -671,11 +698,12 @@ dev:
|
||||
@echo "Starting realtime + tracking as containers (no host Elixir/cargo needed)..."
|
||||
@BACKEND_INTERNAL_URL=http://host.docker.internal:8080 $(COMPOSE) up -d --build realtime tracking
|
||||
@echo ""
|
||||
@echo "Starting backend + consumer + worker + dashboard + admin. Ctrl-C stops them (infra stays up)."
|
||||
@echo "Starting backend + forms + consumer + worker + dashboard + admin. Ctrl-C stops them (infra stays up)."
|
||||
@echo "Dashboard: $(DASHBOARD_URL) Admin: $(ADMIN_URL) Login: dev@warmbly.com / password123"
|
||||
@echo ""
|
||||
@trap 'kill 0' INT TERM; \
|
||||
$(MAKE) --no-print-directory backend & \
|
||||
$(MAKE) --no-print-directory forms & \
|
||||
$(MAKE) --no-print-directory consumer & \
|
||||
$(MAKE) --no-print-directory worker & \
|
||||
$(MAKE) --no-print-directory web & \
|
||||
|
||||
@@ -52,6 +52,7 @@ import (
|
||||
emailverifyapp "github.com/warmbly/warmbly/internal/app/emailverify"
|
||||
"github.com/warmbly/warmbly/internal/app/feature"
|
||||
"github.com/warmbly/warmbly/internal/app/fleet"
|
||||
"github.com/warmbly/warmbly/internal/app/form"
|
||||
"github.com/warmbly/warmbly/internal/app/group"
|
||||
"github.com/warmbly/warmbly/internal/app/guardrail"
|
||||
idempotencyapp "github.com/warmbly/warmbly/internal/app/idempotency"
|
||||
@@ -164,6 +165,7 @@ func main() {
|
||||
var sequenceService sequence.SequenceService
|
||||
var contactService contact.ContactService
|
||||
var segmentService segment.Service
|
||||
var formService form.Service
|
||||
var websiteTrackingService websitetracking.Service
|
||||
var socketService socket.SocketService
|
||||
var uniboxService unibox.UniboxService
|
||||
@@ -1202,6 +1204,20 @@ func main() {
|
||||
if aware, ok := contactService.(contact.SegmentAware); ok {
|
||||
aware.WireSegments(segmentRepository, segmentService)
|
||||
}
|
||||
formRepository := repository.NewFormRepository(primaryDB)
|
||||
formEventRepository := repository.NewFormEventRepository(primaryDB)
|
||||
formService = form.NewService(formRepository)
|
||||
formService.SetContacts(contactService)
|
||||
formService.SetRealtime(streamingPublisher)
|
||||
formService.SetCaptcha(captcha)
|
||||
formService.SetWebhooks(webhookServiceForHandler)
|
||||
formService.SetContactReader(contactRepostory)
|
||||
formService.SetGeo(geoloc)
|
||||
formService.SetLinks(repository.NewFormLinkRepository(primaryDB))
|
||||
formService.SetEvents(formEventRepository)
|
||||
formService.SetDomains(organizationRepoForHandler)
|
||||
go jobs.NewFormEventsRetentionJob(formEventRepository).Start(ctx, 12*time.Hour)
|
||||
go jobs.NewFormsDomainSweep(organizationRepoForHandler).Start(ctx, time.Hour)
|
||||
// A visibly bad import is filed on the workspace's posture. On its own
|
||||
// it can only reach `watch`, which changes nothing.
|
||||
if aware, ok := contactService.(contact.OrgRiskAware); ok && orgRiskService != nil {
|
||||
@@ -1530,6 +1546,9 @@ func main() {
|
||||
if cloudLinkService != nil {
|
||||
tasksService.SetCloudLink(cloudLinkService)
|
||||
}
|
||||
// {{form_link:...}} markers in campaign copy resolve to per-recipient
|
||||
// personalized form URLs at send time.
|
||||
tasksService.SetFormLinks(formService)
|
||||
if w, ok := poolLinkService.(interface {
|
||||
WireScheduler(poollink.WarmupScheduler)
|
||||
}); ok {
|
||||
@@ -1847,6 +1866,7 @@ func main() {
|
||||
RateLimitService: rateLimitService,
|
||||
ContactService: contactService,
|
||||
SegmentService: segmentService,
|
||||
FormService: formService,
|
||||
SequenceService: sequenceService,
|
||||
UniboxService: uniboxService,
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// The forms service: the public face of hosted lead-capture forms. Serves
|
||||
// /f/<publicID> pages, the /forms.js embed loader and public submissions on
|
||||
// its own port, so form traffic never touches the API origin. No Postgres,
|
||||
// no Redis, no event bus: the backend's internal API (BACKEND_INTERNAL_URL +
|
||||
// INTERNAL_API_TOKEN, the same pair the tracking service uses) is its only
|
||||
// dependency.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/formserver"
|
||||
)
|
||||
|
||||
func main() {
|
||||
backendURL := strings.TrimSpace(os.Getenv("BACKEND_INTERNAL_URL"))
|
||||
if backendURL == "" {
|
||||
log.Fatal("BACKEND_INTERNAL_URL is required (the backend's internal API base, e.g. http://localhost:8080)")
|
||||
}
|
||||
token := os.Getenv("INTERNAL_API_TOKEN")
|
||||
if token == "" {
|
||||
log.Fatal("INTERNAL_API_TOKEN is required (must match the backend's)")
|
||||
}
|
||||
|
||||
submitLimit := 0
|
||||
if v := os.Getenv("FORM_IP_RATE_LIMIT"); v != "" {
|
||||
parsed, err := strconv.Atoi(v)
|
||||
if err != nil || parsed <= 0 {
|
||||
log.Fatalf("invalid FORM_IP_RATE_LIMIT %q", v)
|
||||
}
|
||||
submitLimit = parsed
|
||||
}
|
||||
|
||||
staticDir := os.Getenv("FORMS_STATIC_DIR")
|
||||
if staticDir == "" {
|
||||
// The repo-relative default fits `make forms` (run from the root) and
|
||||
// the bare-metal layout (WorkingDirectory=/opt/warmbly).
|
||||
staticDir = "forms/dist"
|
||||
}
|
||||
|
||||
srv, err := formserver.New(formserver.Config{
|
||||
BackendURL: backendURL,
|
||||
InternalToken: token,
|
||||
StaticDir: staticDir,
|
||||
SubmitLimit: submitLimit,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
r, err := srv.Router(splitCSV(os.Getenv("TRUSTED_PROXIES")))
|
||||
if err != nil {
|
||||
log.Fatalf("invalid TRUSTED_PROXIES: %v", err)
|
||||
}
|
||||
|
||||
port := os.Getenv("FORMS_PORT")
|
||||
if port == "" {
|
||||
port = "8090"
|
||||
}
|
||||
log.Printf("forms service listening on :%s (backend %s)", port, backendURL)
|
||||
if err := r.Run(":" + port); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSV(v string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if p := strings.TrimSpace(part); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -85,6 +85,9 @@ BILLING_PROVIDER=none
|
||||
# + TURNSTILE_SECRET to require Cloudflare Turnstile on auth endpoints.
|
||||
# CAPTCHA_PROVIDER=none
|
||||
# TURNSTILE_SECRET=
|
||||
# TURNSTILE_SITE_KEY is the public widget key the hosted form pages render
|
||||
# with when a form enables spam protection (the SPAs carry their own copy).
|
||||
# TURNSTILE_SITE_KEY=
|
||||
|
||||
# === Backend API ===
|
||||
API_HOST=0.0.0.0:8080 # binds all interfaces already
|
||||
@@ -103,6 +106,11 @@ APP_URL=http://localhost:5173
|
||||
CORS_ALLOW_ORIGINS=http://localhost:5173,http://localhost:5174
|
||||
WEBSOCKET_URL=ws://localhost:4000/socket/websocket
|
||||
ENCRYPTED_KEYS_PROVIDER=postgres # backend/consumer: postgres; workers: http (below)
|
||||
# Hosted forms are served by the forms service (see its section below).
|
||||
# FORMS_DOMAIN is the public host routed to it (deploy/nginx/warmbly.conf);
|
||||
# the backend builds share links and embed codes from it. Unset leaves forms
|
||||
# without a public URL.
|
||||
# FORMS_DOMAIN=forms.example.com
|
||||
GEODB_PATH=/app/data/GeoLite2-City.mmdb # optional everywhere; missing = no location labels
|
||||
|
||||
# === Mailbox connections ===
|
||||
@@ -237,6 +245,18 @@ TRACKING_PAGEHIT_RATE_LIMIT_PER_MIN=60
|
||||
# cf-connecting-ip behind Cloudflare). Nothing else is read.
|
||||
# TRACKING_CLIENT_IP_HEADER=x-forwarded-for
|
||||
|
||||
# === Forms service (hosted form pages, embeds, submissions) ===
|
||||
# Its own process (cmd/forms) on FORMS_PORT, so form traffic never touches the
|
||||
# API origin. It serves the built forms/ app (FORMS_STATIC_DIR) and holds no
|
||||
# database: it resolves forms and forwards submissions through the backend
|
||||
# internal API (BACKEND_INTERNAL_URL + INTERNAL_API_TOKEN, the same pair the
|
||||
# tracking service uses). TRUSTED_PROXIES above applies here too, so the
|
||||
# submit limiter sees the real visitor address behind nginx.
|
||||
FORMS_PORT=8090
|
||||
# FORMS_STATIC_DIR=forms/dist
|
||||
# Public form submissions allowed per source IP per 10 minutes.
|
||||
# FORM_IP_RATE_LIMIT=30
|
||||
|
||||
# === Realtime service (Elixir/Phoenix) ===
|
||||
PHX_HOST=localhost
|
||||
PORT=4000
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# The forms service: the public face of hosted lead-capture forms. The React
|
||||
# (TanStack) app in forms/ builds to static files; the Go binary serves them,
|
||||
# the per-form page shells and the same-origin submit API. Always CGO-free —
|
||||
# no event bus, no database, no cache; the backend's internal API is its only
|
||||
# dependency.
|
||||
# Builders run on $BUILDPLATFORM and cross-compile / emit static assets for
|
||||
# $TARGETARCH (no QEMU).
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS appbuilder
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@11.9.0 --activate
|
||||
COPY forms/package.json forms/pnpm-lock.yaml forms/pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY forms/ .
|
||||
RUN pnpm build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||
|
||||
ARG TARGETOS TARGETARCH
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o /out/forms ./cmd/forms
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.23
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
adduser -D -u 1000 warmbly
|
||||
|
||||
COPY --from=builder /out/forms /app/forms
|
||||
COPY --from=appbuilder /app/dist /app/static
|
||||
ENV FORMS_STATIC_DIR=/app/static
|
||||
|
||||
USER warmbly
|
||||
EXPOSE 8090
|
||||
|
||||
# 127.0.0.1, not localhost: busybox wget tries ::1 first but the server binds IPv4.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8090/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/forms"]
|
||||
@@ -1,7 +1,8 @@
|
||||
# nginx site for a Docker-free Warmbly install: the two static frontends plus
|
||||
# reverse proxies for the API, the websocket, and the tracking service.
|
||||
# reverse proxies for the API, the websocket, the tracking service, and the
|
||||
# forms service.
|
||||
# Replace example.com. Every server block reads one certificate covering all
|
||||
# five names from /etc/letsencrypt/live/app.example.com/, so issue it first
|
||||
# six names from /etc/letsencrypt/live/app.example.com/, so issue it first
|
||||
# (certbot certonly --standalone ... --cert-name app.example.com) or nginx
|
||||
# refuses the site. The proxied services bind localhost, so nothing but nginx
|
||||
# is reachable.
|
||||
@@ -98,8 +99,28 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Hosted forms (forms service :8090). Its own process, so form traffic never
|
||||
# touches the API origin: FORMS_DOMAIN=forms.example.com in warmbly.env makes
|
||||
# share links and embed codes point here, and TRUSTED_PROXIES=127.0.0.1/32
|
||||
# lets the service rate-limit on the real visitor address. The service only
|
||||
# serves the public form routes and /health; there is no API to hide.
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name forms.example.com;
|
||||
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.example.com admin.example.com api.example.com ws.example.com t.example.com;
|
||||
server_name app.example.com admin.example.com api.example.com ws.example.com t.example.com forms.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ them is [Deploying without Docker](https://docs.warmbly.com/development/bare-met
|
||||
| Unit | Runs | Reads |
|
||||
|------|------|-------|
|
||||
| `warmbly-backend.service` | `/opt/warmbly/bin/backend` | `/etc/warmbly/warmbly.env` |
|
||||
| `warmbly-forms.service` | `/opt/warmbly/bin/forms` | `/etc/warmbly/warmbly.env` |
|
||||
| `warmbly-consumer.service` | `/opt/warmbly/bin/consumer` | `/etc/warmbly/warmbly.env` |
|
||||
| `warmbly-tracking.service` | `/opt/warmbly/bin/tracking` | `/etc/warmbly/warmbly.env` |
|
||||
| `warmbly-realtime.service` | `/opt/warmbly/realtime/bin/realtime start` | `/etc/warmbly/warmbly.env` |
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Warmbly forms service (hosted form pages, embeds and submissions)
|
||||
Documentation=https://docs.warmbly.com/development/bare-metal/
|
||||
After=network-online.target warmbly-backend.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=warmbly
|
||||
Group=warmbly
|
||||
EnvironmentFile=/etc/warmbly/warmbly.env
|
||||
WorkingDirectory=/opt/warmbly
|
||||
ExecStart=/opt/warmbly/bin/forms
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
LimitNOFILE=65536
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+35
-1
@@ -81,9 +81,15 @@ x-selfhost-env: &selfhost-env
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
|
||||
|
||||
# Captcha. Set both to turn Turnstile on.
|
||||
# Captcha. Set both to turn Turnstile on. The site key also lets hosted
|
||||
# form pages render the widget when a form enables spam protection.
|
||||
CAPTCHA_PROVIDER: ${CAPTCHA_PROVIDER:-none}
|
||||
TURNSTILE_SECRET: ${TURNSTILE_SECRET:-}
|
||||
TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-}
|
||||
|
||||
# Hosted forms. The public host routed to the forms service; share links
|
||||
# and embed codes point here. Unset leaves forms without a public URL.
|
||||
FORMS_DOMAIN: ${FORMS_DOMAIN:-}
|
||||
|
||||
# Deployment mode. Picks the auth defaults below; every one stays
|
||||
# individually overridable.
|
||||
@@ -347,6 +353,34 @@ services:
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
# Public face of hosted forms: pages, embed loader, submissions. No
|
||||
# database; the backend's internal API is its only dependency.
|
||||
forms:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/docker/forms.Dockerfile
|
||||
ports: ["${FORMS_PORT:-8090}:8090"]
|
||||
environment:
|
||||
GIN_MODE: release
|
||||
FORMS_PORT: "8090"
|
||||
FORM_IP_RATE_LIMIT: ${FORM_IP_RATE_LIMIT:-}
|
||||
TRUSTED_PROXIES: ${FORMS_TRUSTED_PROXIES:-}
|
||||
# Overridable so a native `make backend` can be pointed at from a
|
||||
# container via host.docker.internal, mirroring the tracking service.
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL:-http://backend:8080}
|
||||
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-local-dev-internal-token}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
backend: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8090/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
consumer:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
|
||||
@@ -62,12 +62,15 @@ All paths below are relative to the versioned base URL `https://api.warmbly.com/
|
||||
| POST | `/campaigns/:id/start` | `SEND_CAMPAIGNS` |
|
||||
| POST | `/campaigns/:id/stop` | `SEND_CAMPAIGNS` |
|
||||
| GET | `/campaigns/:id/logs` | `READ_CAMPAIGNS` |
|
||||
| GET | `/campaigns/:id/forms` | `READ_CAMPAIGNS` |
|
||||
| GET/POST/PATCH/DELETE | `/campaigns/:id/steps[/:sid]` | `READ_CAMPAIGNS` for GET, `WRITE_CAMPAIGNS` otherwise |
|
||||
| PATCH | `/campaigns/:id/step-layout` | `WRITE_CAMPAIGNS` |
|
||||
| POST | `/generation/write` | `WRITE_CAMPAIGNS` |
|
||||
| POST | `/generation/edit` | `WRITE_CAMPAIGNS` |
|
||||
| POST | `/generation/ai-variable` | `WRITE_CAMPAIGNS` |
|
||||
|
||||
`GET /campaigns/:id/forms` reports the forms this campaign links to and what its recipients did with them: personalized links handed out, who opened one, who started filling it in and who submitted. See the [forms guide](/guides/forms/).
|
||||
|
||||
### Contacts
|
||||
|
||||
| Method | Path | API Permission |
|
||||
@@ -124,6 +127,28 @@ AI contact research charges credits (2 per run, billable even when it finds noth
|
||||
|
||||
Segments are saved contact audiences: a condition list plus per-contact manual overrides, evaluated live. Contact scopes cover them because a segment is a view over contacts; enrolling one into a campaign writes leads, so that call takes the campaign write scope, as does managing a campaign's linked segments (`/campaigns/:id/segments` above). `POST /contacts/search` and `POST /contacts/export` accept `segment_ids` to scope any contact query to a segment. See [contacts](/api/reference/contacts/#segments) for the condition format.
|
||||
|
||||
### Forms
|
||||
|
||||
| Method | Path | API Permission |
|
||||
|--------|------|----------------|
|
||||
| GET | `/forms` | `READ_CONTACTS` |
|
||||
| GET | `/forms/config` | `READ_CONTACTS` |
|
||||
| POST | `/forms` | `WRITE_CONTACTS` |
|
||||
| GET | `/forms/:id` | `READ_CONTACTS` |
|
||||
| PATCH | `/forms/:id` | `WRITE_CONTACTS` |
|
||||
| DELETE | `/forms/:id` | `WRITE_CONTACTS` |
|
||||
| GET | `/forms/:id/submissions` | `READ_CONTACTS` |
|
||||
| DELETE | `/forms/:id/submissions/:sid` | `WRITE_CONTACTS` |
|
||||
| GET | `/forms/:id/stats` | `READ_CONTACTS` |
|
||||
| GET | `/forms/domain` | `READ_CONTACTS` |
|
||||
| PUT | `/forms/domain` | `WRITE_CONTACTS` |
|
||||
| POST | `/forms/domain/verify` | `WRITE_CONTACTS` |
|
||||
| GET | `/forms/:id/links/:contactID` | `WRITE_CONTACTS` |
|
||||
| POST | `/forms/:id/assets/:kind` | `WRITE_CONTACTS` |
|
||||
| DELETE | `/forms/:id/assets/:kind` | `WRITE_CONTACTS` |
|
||||
|
||||
Hosted lead-capture forms; see the [forms guide](/guides/forms/). Contact scopes cover them because a form exists to create contacts. Minting a personalized link is a `GET` on top of an upsert, so retries always return the same token. The `/forms/domain` routes are the workspace-wide custom forms domain and additionally need the `manage_settings` organization permission for JWT callers; `PUT` resolves the record as part of saving, and only a verified domain is ever used to build a URL. The public page (`/f/:public_id`), its submit endpoint and the embed loader (`/forms.js`) are served by the standalone forms service on its own host (`FORMS_DOMAIN`), not the API origin, and take no authentication; the unguessable public id is the capability, and the JSON endpoints additionally require the render token the page shell carries.
|
||||
|
||||
### Lead sync
|
||||
|
||||
Saved Google Sheets sources that upsert contacts on demand. Gated under the contact write scope because a sync ultimately writes contacts. Nothing syncs on a timer: a source only runs when `/lead-sync/sources/:id/sync` is called.
|
||||
|
||||
@@ -510,6 +510,7 @@ The full catalog is available at `GET /webhooks/event-types`. Below is the compl
|
||||
| `contact.created` | A contact is created. | |
|
||||
| `contact.updated` | A contact's fields change. | |
|
||||
| `contact.deleted` | A contact is deleted. | |
|
||||
| `form.submitted` | A hosted form received a submission. Carries the form id and name, the submission id, the answers, and the contact id when one was created or matched. | |
|
||||
|
||||
### CRM
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ flowchart LR
|
||||
| Backend | Go (Gin) | Control | REST API, auth, business logic, worker orchestration |
|
||||
| Consumer | Go | Control | Event bus processor → Postgres |
|
||||
| Tracking | Rust (Axum) | Control | Open/click pixels and redirects → event bus |
|
||||
| Forms | Go (Gin) + React (TanStack) | Control | Hosted form pages (`forms/` app), embeds and public submissions → backend internal API |
|
||||
| Realtime | Elixir (Phoenix) | Control | WebSocket fanout |
|
||||
| Worker | Go | Execution | One per VPS; subscribes to a per-worker event bus topic; never opens a Postgres connection |
|
||||
| Web | React (Vite) | n/a | Dashboard frontend |
|
||||
|
||||
@@ -15,12 +15,14 @@ flowchart LR
|
||||
NG --> API["backend :8080"]
|
||||
NG --> RT["realtime :4000"]
|
||||
NG --> TRK["tracking :3000"]
|
||||
NG --> FRM["forms :8090"]
|
||||
subgraph HOST["One host, systemd"]
|
||||
API --> PG[("PostgreSQL 16")]
|
||||
API --> BUS{{"NATS JetStream"}}
|
||||
API --> RD[("Redis 7")]
|
||||
BUS --> CONS["consumer"] --> PG
|
||||
TRK --> BUS
|
||||
FRM --> API
|
||||
RT --- RD
|
||||
BUS --> W["worker"]
|
||||
end
|
||||
@@ -38,9 +40,9 @@ flowchart LR
|
||||
| Go 1.25 | Builds the backend, consumer, worker, `migrate` and `warmblyctl` |
|
||||
| Rust (stable, 1.93 or newer) | Builds the tracking service |
|
||||
| Elixir 1.18 on OTP 26 | Builds the realtime service |
|
||||
| Node 22 and pnpm | Builds the dashboard and admin panel |
|
||||
| nginx | Serves the static frontends and terminates TLS for the API, websocket and tracking hosts |
|
||||
| Five DNS names | `app`, `admin`, `api`, `ws` and `t` under your domain, all pointing at the host |
|
||||
| Node 22 and pnpm | Builds the dashboard, admin panel and forms app |
|
||||
| nginx | Serves the static frontends and terminates TLS for the API, websocket, tracking and forms hosts |
|
||||
| Six DNS names | `app`, `admin`, `api`, `ws`, `t` and `forms` under your domain, all pointing at the host |
|
||||
|
||||
The toolchains are only needed on the machine that builds. If you would rather not install compilers on the production host, build on another machine of the same architecture and copy `/opt/warmbly` over; nothing below is path-dependent beyond the values you put in the env file.
|
||||
|
||||
@@ -145,15 +147,15 @@ Checkout a release tag rather than `main` when you want a known version: `git -C
|
||||
|
||||
### Go services
|
||||
|
||||
Five binaries come out of one module. Static builds, so the production host needs no Go runtime:
|
||||
Six binaries come out of one module. Static builds, so the production host needs no Go runtime:
|
||||
|
||||
```bash
|
||||
cd /opt/warmbly/src
|
||||
export CGO_ENABLED=0
|
||||
for cmd in backend consumer worker migrate warmblyctl; do
|
||||
for cmd in backend forms consumer worker migrate warmblyctl; do
|
||||
go build -ldflags="-s -w" -o "out/$cmd" "./cmd/$cmd"
|
||||
done
|
||||
sudo install -m 0755 out/backend out/consumer out/worker out/migrate out/warmblyctl /opt/warmbly/bin/
|
||||
sudo install -m 0755 out/backend out/forms out/consumer out/worker out/migrate out/warmblyctl /opt/warmbly/bin/
|
||||
sudo ln -sf /opt/warmbly/bin/warmblyctl /usr/local/bin/warmblyctl
|
||||
```
|
||||
|
||||
@@ -198,16 +200,19 @@ Build on a machine with the same libc as the host: a release built on Alpine (mu
|
||||
|
||||
<Step>
|
||||
|
||||
### Dashboard and admin panel
|
||||
### Dashboard, admin panel and forms app
|
||||
|
||||
Both are static Vite builds. No URL is baked in at build time; the frontends read `config.js` at runtime, which you write in the next step.
|
||||
All three are static Vite builds. No URL is baked in at build time; the dashboard and admin read `config.js` at runtime (next step), and the forms app is served by the forms binary itself, which looks for it at `forms/dist` under its working directory.
|
||||
|
||||
```bash
|
||||
cd /opt/warmbly/src/web && pnpm install --frozen-lockfile && pnpm build
|
||||
cd /opt/warmbly/src/admin && pnpm install --frozen-lockfile && pnpm build
|
||||
sudo rm -rf /opt/warmbly/web /opt/warmbly/admin
|
||||
cd /opt/warmbly/src/forms && pnpm install --frozen-lockfile && pnpm build
|
||||
sudo rm -rf /opt/warmbly/web /opt/warmbly/admin /opt/warmbly/forms
|
||||
sudo cp -r /opt/warmbly/src/web/dist /opt/warmbly/web
|
||||
sudo cp -r /opt/warmbly/src/admin/dist /opt/warmbly/admin
|
||||
sudo install -d /opt/warmbly/forms
|
||||
sudo cp -r /opt/warmbly/src/forms/dist /opt/warmbly/forms/dist
|
||||
```
|
||||
|
||||
</Step>
|
||||
@@ -440,7 +445,7 @@ To apply migrations without starting the API, for instance in a deploy script, `
|
||||
### Start the rest
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now warmbly-consumer warmbly-tracking warmbly-realtime warmbly-worker
|
||||
sudo systemctl enable --now warmbly-forms warmbly-consumer warmbly-tracking warmbly-realtime warmbly-worker
|
||||
systemctl status 'warmbly-*' --no-pager
|
||||
```
|
||||
|
||||
@@ -449,6 +454,7 @@ Each answers on its own port:
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health # backend
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/health # tracking
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/health # forms
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4000/health # realtime
|
||||
```
|
||||
|
||||
@@ -460,13 +466,13 @@ The consumer and worker have no port; `journalctl -u warmbly-worker` shows the w
|
||||
|
||||
### Put nginx in front
|
||||
|
||||
[`deploy/nginx/warmbly.conf`](https://github.com/warmbly/warmbly/blob/main/deploy/nginx/warmbly.conf) serves the two static frontends and proxies the API, websocket and tracking hosts. It references Let's Encrypt certificate files by path, so the certificates have to exist before nginx will accept the site. Issue them first, in standalone mode with nothing on port 80, then enable the site:
|
||||
[`deploy/nginx/warmbly.conf`](https://github.com/warmbly/warmbly/blob/main/deploy/nginx/warmbly.conf) serves the two static frontends and proxies the API, websocket, tracking and forms hosts. It references Let's Encrypt certificate files by path, so the certificates have to exist before nginx will accept the site. Issue them first, in standalone mode with nothing on port 80, then enable the site:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop nginx
|
||||
sudo certbot certonly --standalone --agree-tos -m you@yourdomain.com \
|
||||
-d app.yourdomain.com -d admin.yourdomain.com -d api.yourdomain.com \
|
||||
-d ws.yourdomain.com -d t.yourdomain.com \
|
||||
-d ws.yourdomain.com -d t.yourdomain.com -d forms.yourdomain.com \
|
||||
--cert-name app.yourdomain.com
|
||||
|
||||
sudo cp /opt/warmbly/src/deploy/nginx/warmbly.conf /etc/nginx/sites-available/warmbly.conf
|
||||
@@ -475,7 +481,7 @@ sudo ln -s /etc/nginx/sites-available/warmbly.conf /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl start nginx
|
||||
```
|
||||
|
||||
`--cert-name app.yourdomain.com` puts all five names on one certificate under `/etc/letsencrypt/live/app.yourdomain.com/`, which is the path every server block in the shipped file uses. Renewals run from the timer certbot installs; because the certificate was issued standalone, tell it how to reload nginx once:
|
||||
`--cert-name app.yourdomain.com` puts all six names on one certificate under `/etc/letsencrypt/live/app.yourdomain.com/`, which is the path every server block in the shipped file uses. Renewals run from the timer certbot installs; because the certificate was issued standalone, tell it how to reload nginx once:
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run --pre-hook 'systemctl stop nginx' --post-hook 'systemctl start nginx'
|
||||
@@ -562,7 +568,7 @@ Rebuild the artifacts that changed, then restart. Migrations are forward-only an
|
||||
cd /opt/warmbly/src && sudo git pull # or checkout a newer tag
|
||||
# repeat the build steps for the services that changed, then:
|
||||
sudo systemctl restart warmbly-backend
|
||||
sudo systemctl restart warmbly-consumer warmbly-tracking warmbly-realtime warmbly-worker
|
||||
sudo systemctl restart warmbly-forms warmbly-consumer warmbly-tracking warmbly-realtime warmbly-worker
|
||||
```
|
||||
|
||||
Frontend rebuilds overwrite `config.js` when you copy `dist/` over, so rewrite it afterwards (or keep the two files somewhere and copy them back). A build script that does all of it in order is worth writing the second time you upgrade.
|
||||
@@ -602,11 +608,11 @@ Restore into an empty database before the backend starts, then start it and it a
|
||||
## Quick reference
|
||||
|
||||
```
|
||||
/opt/warmbly/bin/{backend,consumer,worker,tracking,migrate,warmblyctl}
|
||||
/opt/warmbly/bin/{backend,forms,consumer,worker,tracking,migrate,warmblyctl}
|
||||
/opt/warmbly/realtime/bin/realtime mix release
|
||||
/opt/warmbly/web, /opt/warmbly/admin static builds + config.js
|
||||
/opt/warmbly/src the checkout (worker installer is served from here)
|
||||
/etc/warmbly/warmbly.env backend, consumer, tracking, realtime
|
||||
/etc/warmbly/warmbly.env backend, forms, consumer, tracking, realtime
|
||||
/etc/warmbly/worker.env worker
|
||||
/var/lib/warmbly/blobs BLOB_FS_ROOT
|
||||
/etc/systemd/system/warmbly-*.service from deploy/systemd/
|
||||
|
||||
@@ -113,6 +113,7 @@ Every emailed link (password reset, invitation, the first-run claim link) is bui
|
||||
| `PHX_HOST` | The realtime service's own hostname | `localhost` | yes |
|
||||
| `TRACKING_DOMAIN` | The host that serves open pixels and click links, and the `CNAME` value customers point their own tracking subdomain at. Use a separate, neutral domain in production. Unset means campaign mail ships with no pixel and unwrapped links, and no custom tracking domain can verify | `localhost:3000` under compose, otherwise unset | no |
|
||||
| `TRACKING_SERVICE_URL` | Where the backend reaches the tracking service internally | unset | yes |
|
||||
| `FORMS_DOMAIN` | The host hosted form pages and embeds are served on (`forms.example.com`, routed to the forms service). The backend builds share links and embed codes from it; unset leaves forms without a public URL | unset | no (read per request) |
|
||||
|
||||
<Callout type="warn" title="Setting PUBLIC_HOST turns localhost off">
|
||||
Once `PUBLIC_HOST` is set, every derived URL uses it and `http://localhost:5173` stops working, because a `localhost` origin is no longer in `CORS_ALLOW_ORIGINS`. To keep both, list them yourself in `CORS_ALLOW_ORIGINS`.
|
||||
@@ -172,6 +173,7 @@ Full behavior, including what each registration mode does to the sign-up form, i
|
||||
| `TURNSTILE_SECRET` | Cloudflare Turnstile secret, read by the backend | unset | yes |
|
||||
| `WARMBLY_TURNSTILE_KEY` | The Turnstile site key, read by the dashboard and admin panel at container start | a test key under compose | yes (container start) |
|
||||
| `TURNSTILE_BYPASS_TOKEN` | A token that skips verification. Only honoured when `APP_ENV=dev` | unset | yes |
|
||||
| `TURNSTILE_SITE_KEY` | The public Turnstile widget key. The backend hands it to the forms service, which renders it on hosted form pages that enable spam protection. The frontends carry their own copy in `WARMBLY_TURNSTILE_KEY` | unset | no (read per request) |
|
||||
|
||||
`CAPTCHA_PROVIDER` has no constant default. Unset, it resolves to `turnstile` when `TURNSTILE_SECRET` holds a value and to `none` when it does not, so configuring the secret is what turns captcha on and clearing it is what turns captcha off. The panel reports the resolved value with source `derived`.
|
||||
|
||||
@@ -379,6 +381,19 @@ Delayed sends run through the local poller, so the backend must be running for s
|
||||
| `SENTRY_DSN` | Error reporting. Optional in every environment, including `prod` | unset |
|
||||
| `APNS_KEY` or `APNS_KEY_PATH`, `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_TOPIC` | Mobile push on backend and consumer. Partial configuration disables push with a warning, never a crash | unset |
|
||||
|
||||
## Forms service
|
||||
|
||||
The public face of hosted forms (`cmd/forms`): it serves the React (TanStack) form app built from `forms/`, the per-form page shells, the embed loader and public submissions, on its own port so form traffic never touches the API origin. It reads its own environment; none of these appear in the admin panel except `FORM_IP_RATE_LIMIT`.
|
||||
|
||||
| Variable | What it does | Default |
|
||||
|---|---|---|
|
||||
| `FORMS_PORT` | Listen port | `8090` |
|
||||
| `FORMS_STATIC_DIR` | The built forms app (`pnpm build` output). The service exits at boot when `index.html` is missing there | `forms/dist` |
|
||||
| `BACKEND_INTERNAL_URL` | Where the service resolves forms and forwards submissions, the same variable the tracking service uses. **Required**: the service exits at boot without it | none |
|
||||
| `INTERNAL_API_TOKEN` | Bearer token for those calls, matching the backend's. **Required**: the service exits at boot on an empty value | none |
|
||||
| `FORM_IP_RATE_LIMIT` | Public form submissions allowed per source IP per 10 minutes, per forms-service instance | `30` |
|
||||
| `TRUSTED_PROXIES` | CIDRs whose `X-Forwarded-For` the service believes, same convention as the backend. Empty trusts nothing and uses the socket peer; set it behind a reverse proxy or the submit limiter throttles the proxy's address instead of the visitor's | empty |
|
||||
|
||||
## Tracking service
|
||||
|
||||
The Rust open and click service. It reads its own environment, so these have to be set on that container, and none of them appear in the admin panel.
|
||||
|
||||
@@ -17,6 +17,7 @@ flowchart LR
|
||||
API --> BUS{{"NATS JetStream"}}
|
||||
BUS --> CONS["consumer"] --> PG
|
||||
TRK["tracking :3000"] --> BUS
|
||||
FRM["forms :8090"] --> API
|
||||
RT["realtime :4000"] --- RD[("Redis")]
|
||||
end
|
||||
BUS --> W1["worker"]
|
||||
@@ -159,13 +160,14 @@ Rotating the secrets means recreating the containers, which `make up` does. Chan
|
||||
make status
|
||||
```
|
||||
|
||||
`backend`, `postgres`, `redis` and `nats` report `(healthy)`. The backend applies its migrations on boot, so it takes a few seconds longer than the rest. `realtime` and `tracking` have no healthcheck and show as `running`.
|
||||
`backend`, `forms`, `postgres`, `redis` and `nats` report `(healthy)`. The backend applies its migrations on boot, so it takes a few seconds longer than the rest. `realtime` and `tracking` have no healthcheck and show as `running`.
|
||||
|
||||
Each entry point answers on its own port:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/health # backend
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/health # tracking
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8090/health # forms
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/health # realtime
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5173 # dashboard
|
||||
```
|
||||
@@ -181,13 +183,14 @@ The admin panel's **System Status** page shows the same probes plus the platform
|
||||
| web / admin | 5173 / 5174 | Dashboard and admin panel (static nginx builds, URLs injected at container start) |
|
||||
| backend | 8080 | REST API; applies migrations on every boot; `GET /health` |
|
||||
| tracking | 3000 | Open pixels and click redirects; `GET /health` |
|
||||
| forms | 8090 | Hosted form pages, embeds and public submissions; `GET /health` |
|
||||
| realtime | 4000 | WebSocket fanout; `GET /health` |
|
||||
| consumer / worker | none | Event processor; send and sync executor |
|
||||
| postgres / redis | 15432 / 16379 | PostgreSQL 16; cache and realtime bridge |
|
||||
| nats | 4222 (monitoring 8222) | Event bus (JetStream) |
|
||||
| mailpit | 18025 (SMTP 11025), bound to localhost | Not started by `make up`. An SMTP sink for the demo environment, in the `sandbox` profile. Its UI has no authentication, so it is never published to the network |
|
||||
|
||||
Ten containers, idling near 300 MB in total. Nothing that only the demo environment needs is started: the mail catcher and the local IMAP server sit in the `sandbox` profile and come up only when `make sandbox`, `make dev` or `make infra` name them.
|
||||
Eleven containers, idling near 300 MB in total. Nothing that only the demo environment needs is started: the mail catcher and the local IMAP server sit in the `sandbox` profile and come up only when `make sandbox`, `make dev` or `make infra` name them.
|
||||
|
||||
There is no separate migration step. Migrations are embedded in the backend binary, and a standalone `/app/migrate` binary ships in the image as well.
|
||||
|
||||
@@ -219,6 +222,7 @@ These are the ones that break things quietly when they drift, because each servi
|
||||
| consumer | The same shared block as the backend. It writes to Postgres, so it needs `PRIMARY_DB` and the encryption keys |
|
||||
| worker | No database. It needs the event bus, `REDIS`, the encryption keys, `ENCRYPTED_KEYS_BACKEND_URL` plus the internal token, and the `BOX_*` OAuth clients |
|
||||
| tracking | The event bus, `BACKEND_INTERNAL_URL`, and `INTERNAL_API_TOKEN` |
|
||||
| forms | No database and no event bus: only `BACKEND_INTERNAL_URL` and `INTERNAL_API_TOKEN` |
|
||||
| realtime | `JWT_SECRET`, `SECRET_KEY_BASE`, `DATABASE_URL`, `REDIS_URL`, `PHX_HOST` |
|
||||
| web / admin | Only `WARMBLY_*` URLs, read at container start and written into `/config.js`. The same image runs anywhere |
|
||||
|
||||
|
||||
@@ -67,9 +67,9 @@ iterating on those services themselves.
|
||||
Day-one:
|
||||
|
||||
```bash
|
||||
make dev # one command: infra + migrations + seed + backend/consumer/worker + realtime + tracking + dashboard + admin
|
||||
make dev # one command: infra + migrations + seed + backend/forms/consumer/worker + realtime + tracking + dashboard + admin
|
||||
make infra # postgres, redis, nats, mailpit (leave running)
|
||||
make run # backend + consumer + worker (native, after make infra)
|
||||
make run # backend + forms + consumer + worker (native, after make infra)
|
||||
make up # the whole no-cloud stack in Docker (docker compose up --build)
|
||||
make gen-key # print a KMS_LOCAL_MASTER_KEY for a real deployment
|
||||
make seed # load rich fixtures (3 orgs, 6 mailboxes, a campaign)
|
||||
@@ -221,9 +221,11 @@ its own target with the full dev environment pre-wired (no manual env exports):
|
||||
```bash
|
||||
make infra # once: the backing services in Docker
|
||||
make backend # API on :8080 (applies migrations on boot)
|
||||
make forms # public forms service on :8090 (builds the forms/ app, then serves it)
|
||||
make forms-web # Vite dev server for forms/ on :5175 when editing the app itself
|
||||
make consumer # event consumer (NATS -> postgres)
|
||||
make worker # send/sync worker (run more for parallelism)
|
||||
make run # backend + consumer + worker in one terminal
|
||||
make run # backend + forms + consumer + worker in one terminal
|
||||
make web # dashboard dev server on :5173
|
||||
make admin # admin app on :5174
|
||||
make site # marketing site on :4321
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: "Forms"
|
||||
description: "Build a hosted lead-capture form, style it to match your site, embed it anywhere, and turn every submission into a contact."
|
||||
---
|
||||
|
||||
Forms turn website visitors into contacts. You build a form in the dashboard, publish it to a hosted page, and either share the link or embed the form on any website. Every submission is stored, and when it carries an email address it creates or updates a contact, files it under the categories you chose, and can drop it straight into a campaign.
|
||||
|
||||
Open **Forms** in the sidebar. Viewing takes the same permission as viewing contacts; building and publishing take the manage-contacts permission.
|
||||
|
||||
## Building a form
|
||||
|
||||
**New form** creates a draft seeded with first name, last name and email, and opens the builder. The canvas in the middle is a live preview: what you see is exactly what the hosted page renders.
|
||||
|
||||
- Add blocks by clicking them in the left palette or dragging them onto the canvas.
|
||||
- Click a block to edit it in the right panel: label, placeholder, help text, required, and width. Two half-width fields share a row.
|
||||
- Drag the grip on a selected block to reorder; the copy and trash buttons duplicate and remove it.
|
||||
|
||||
### Field types
|
||||
|
||||
| Block | Collects |
|
||||
|---|---|
|
||||
| Text, Email, Phone, Long text, Number, Date | A single value |
|
||||
| Dropdown, Radio buttons | One of the options you define |
|
||||
| Checkbox group | Any number of the options |
|
||||
| Single checkbox | A yes, for consent lines like "I agree" |
|
||||
| Hidden field | A constant you set, invisible to visitors. Useful for tagging which page or campaign a form sits on |
|
||||
| Heading, Text block, Divider | Nothing. Layout only |
|
||||
| Page break | Nothing. Everything after it starts a new page |
|
||||
|
||||
### Pages
|
||||
|
||||
Drop a **Page break** block anywhere to split the form into steps. Visitors move through pages with Next and Back, each page validates before advancing, and an optional progress bar (Design tab) shows how far along they are. A break can carry a page title, which labels the page on the form and in analytics. Avoid a break as the very first or last block; an empty page renders nothing and the builder warns before publishing.
|
||||
|
||||
### Where answers go
|
||||
|
||||
Every answer is kept verbatim on the submission. On top of that, each input field can feed the contact:
|
||||
|
||||
- **Contact columns.** A field mapped to first name, last name, email, company or phone fills that column. The email field always maps to email, and only one field can map to each column.
|
||||
- **Custom fields.** An unmapped field stores its answer as a contact custom field named after the label, so a "Company size" question becomes a `Company size` custom field you can use in [segments](/guides/segments/) and email personalization.
|
||||
|
||||
A form needs an email answer to create a contact. Forms without an email field still collect submissions; they just never touch the contact list.
|
||||
|
||||
## Design
|
||||
|
||||
The **Design** tab styles the form while the canvas updates live. The defaults are a clean, neutral card that looks right on most sites; changing nothing is a fine choice.
|
||||
|
||||
- **Themes.** One-click presets (light, dark, gradients, serif and more) that set the colors and font in one go. A theme never touches your layout, pages or button text, and every color stays editable afterwards.
|
||||
- **Layout.** *Card* is the classic centered box. *Wide* drops the box and lets the fields sit directly on the page background. *Split* adds a side panel that shows your cover image (or the page background) next to the form.
|
||||
- **Mode.** *Classic* shows each page of fields at once. *Focus* asks one question per screen, advancing on Enter, in the style of full-page survey tools.
|
||||
- **Colors and type.** Every color (page, card, inputs, labels, placeholder, accent, button), eight fonts, corner radius, spacing, width and alignment. Each color opens a full picker: drag the shade area, pick a hue, paste a hex value, or use one of the presets. On Chromium browsers there is also an eyedropper for sampling a color anywhere on screen. The page background can be a two-color gradient.
|
||||
- **Header bar.** An optional bar carrying your logo and a title. **Across the page** runs it edge to edge above everything; **with the form** puts it on the form itself, above the fields with a rule under it, which reads as part of the card rather than as chrome above it. Its contents sit left, centered or apart (logo one side, title the other), following the form's own alignment until you choose otherwise; centering stacks the logo above the title, since that is the shape a centered lockup wants. A page-width bar can take its own background color and stay on screen while the form scrolls; neither applies to an inline header, so neither is offered for one. The header takes the logo by default, and turning that off leaves the logo in its own placement and gives the header the title alone. A header with neither a logo nor a title is skipped on the live page.
|
||||
- **Side panel.** On the Split layout, the panel takes a title and a paragraph of text on top of the cover image, rendered in white with a soft shadow so it stays readable on any photo.
|
||||
- **Branding and backgrounds.** Upload a logo (PNG or JPG, up to 1 MB), a cover image for the Split layout, and a background image behind the whole page (both up to 4 MB). The logo renders small, medium or large, and on the Card layout it can sit on the card or above it on the page background; check contrast if you move it out, since a dark page can swallow a dark logo. A background image can fill, fit or tile, and the **veil** slider fades the page color over it: raise it when text on a busy photo gets hard to read.
|
||||
|
||||
## Settings
|
||||
|
||||
The **Settings** tab controls what a submission does:
|
||||
|
||||
- **Success message** is shown after submitting, or set a **redirect URL** to send the visitor to your own thank-you page instead.
|
||||
- **Add to categories** files every submitted contact under the categories you pick, for example "Website leads".
|
||||
- **Add to campaign** enrolls new contacts as leads in the campaign you pick. Sending still follows the campaign's own schedule, limits and windows; a form never causes immediate mail.
|
||||
- **Spam protection** and **allowed embed domains** are covered below.
|
||||
|
||||
## Publishing and sharing
|
||||
|
||||
**Publish** puts the form online; **Unpublish** takes it offline again (the page starts answering not found). Archiving keeps the data but takes the page offline too. Edits to a published form go live when you save.
|
||||
|
||||
The **Share** tab has four ways to use a published form:
|
||||
|
||||
- **Hosted link.** The form's own page. Share it in an email signature or on social; no website needed.
|
||||
- **JavaScript embed** (recommended). A script tag plus a `div` with a `data-warmbly-form` attribute. The form renders in place and sizes itself to its content, so there is no clipped bottom edge or dead space.
|
||||
- **Popup.** The same script plus any element with a `data-warmbly-popup` attribute. Clicking it opens the form in an overlay, which closes on Escape, on the backdrop, and shortly after a successful submit.
|
||||
- **Plain iframe.** For site builders that strip scripts. Fixed height, so set the `height` attribute to fit your form.
|
||||
|
||||
These work in WordPress, Elementor, Webflow, Shopify, Framer and anywhere else custom HTML is allowed. The form itself always runs on the Warmbly origin, so the host page's CSS cannot break it and no visitor data flows through the host page. The script also fires a `warmbly:submitted` DOM event on the host page after each successful submit, if you want to run your own tracking.
|
||||
|
||||
### Personalized links
|
||||
|
||||
A personalized link is the form URL with a per-contact ticket (`?t=...`). Opening it pre-fills the contact's mapped fields, and the visit and submission are attributed to that contact even when they type a different email. There is no separate email verification step; holding the link is the identity, the same way an unsubscribe link works.
|
||||
|
||||
Two ways to get them:
|
||||
|
||||
- **In campaign emails.** Use the form button in the editor toolbar, or type `{{` and pick the form under **Forms**. Either inserts a chip you can click to swap the form later. At send time every recipient gets their own link, and it is click-tracked like any other link.
|
||||
- **For one contact.** The Share tab has a contact search; Copy link mints that contact's URL for use anywhere.
|
||||
|
||||
Personalized links make the analytics personal too: the form's Analytics tab shows which contacts opened the form, how far they got, and whether they finished.
|
||||
|
||||
### Custom domain
|
||||
|
||||
Form links go out on a shared host by default, which means their reputation and their look are the sum of everyone using it. Pointing a subdomain of the domain you send from at that host puts every form link on your own name instead: the hosted page, the embeds, and the personalized links inside campaign emails.
|
||||
|
||||
This matters more than it looks. Click tracking already rewrites links in the HTML part of an email onto your [tracking domain](/guides/mailboxes/#custom-tracking-domain), but the plain-text alternative is never rewritten, so without a custom forms domain the shared host still appears in every message that carries a form link.
|
||||
|
||||
Type the subdomain into **Custom domain** on any form's Share tab, add the `CNAME` it shows you at your DNS provider, then press **Save and verify**. It is a workspace setting, so it covers every form at once and needs the manage-settings permission.
|
||||
|
||||
Verification reports the same outcomes as a tracking domain: verified, no record yet, points at something else, or nothing to point at when the install has no forms host. Until it verifies, links keep working on the shared host, so an unverified domain never breaks a form. Warmbly re-checks hourly, so a record that finishes propagating starts being used on its own, and one that later stops pointing here stops being used instead of quietly serving links from a host that no longer answers.
|
||||
|
||||
Links already sent keep resolving on whichever host they were built with. Only new links pick up a change.
|
||||
|
||||
### Forms in a campaign
|
||||
|
||||
A campaign that links to a form grows a **Forms** panel on its overview page, listing every form it references with, per form: how many recipients were handed a personalized link, how many opened it, how many started filling it in, how many submitted, and the resulting submission rate. A form appears there as soon as it is written into a step, at zero, so you can confirm the wiring before launching.
|
||||
|
||||
Because each recipient's link is unique, those numbers are per contact rather than per click: the same person opening twice counts once, and every submission names the campaign that sent them. The form's own Analytics tab has the mirror image, a **Campaigns** breakdown of where its traffic came from, and every submission row shows its campaign.
|
||||
|
||||
## Submissions
|
||||
|
||||
The **Submissions** tab is a table of everyone who engaged, newest activity first and live: a new submission appears for everyone with the form open, without a refresh. Alongside completed responses it lists people who opened a personalized link but have not finished, marked **In progress** with the page they reached, so a stalled lead is visible instead of invisible. Search and the status filter narrow the list; click a response for every answer; the trash button deletes it (the contact it created is kept), and selecting rows offers a bulk delete.
|
||||
|
||||
Each submitted contact carries `form` as its first-touch source, gets a "Submitted a form" entry on its activity timeline, and can be filtered in segments by source. The form's page views, submission count and conversion rate show on the Forms list.
|
||||
|
||||
Submissions also fire the `form.submitted` [webhook](/guides/webhooks/), carrying the answers and the contact id, so you can forward leads to any external system.
|
||||
|
||||
## Analytics
|
||||
|
||||
The **Analytics** tab shows the funnel for the last 7, 30 or 90 days:
|
||||
|
||||
- **Totals and trend.** Views, starts (first interaction), submissions and the completion rate, with a daily chart.
|
||||
- **Page funnel.** On multi-page forms, how many visitors reached each page and how many of those finished, so you can see exactly where people drop off.
|
||||
- **Where visitors come from.** Top referrer domains, countries and device types. Location is resolved to country only and the visitor's IP address is never stored.
|
||||
- **Identified visitors.** Contacts who opened the form through a personalized link: when they were last seen, the furthest page they reached, and whether they completed it.
|
||||
|
||||
Funnel events are kept for 180 days; the lifetime view and submission counters on the Forms list are kept forever.
|
||||
|
||||
## Spam protection
|
||||
|
||||
Every published form has layered protection with nothing to configure:
|
||||
|
||||
- a honeypot field invisible to people but filled in by naive bots
|
||||
- a minimum fill time, discarding submissions that arrive faster than a human can type
|
||||
- per-address rate limits on the submit endpoint
|
||||
- a size cap on every submission
|
||||
- a render token: the form's data and submit endpoints only answer requests carrying a token minted when the page was served, so scripts cannot scrape form definitions or spray submissions without loading the page. A tab left open for more than 12 hours shows a refresh prompt instead of failing silently
|
||||
|
||||
Two more controls are yours:
|
||||
|
||||
- **Captcha challenge** adds a Cloudflare Turnstile check to the form. The toggle appears once the operator has configured Turnstile for the instance.
|
||||
- **Allowed embed domains** limits which sites may embed the form. With domains listed, browsers refuse to frame the page anywhere else (a domain covers its subdomains); empty allows any site. The hosted link keeps working either way.
|
||||
|
||||
## Data and portability
|
||||
|
||||
Forms, their design, their logo and cover images, their submissions, personalized link tickets and funnel events all belong to the **Contacts** data group of a [workspace archive](/guides/workspace-export-import/). The public form id travels with them, so embed codes installed on your website keep working after a move to another instance (point your embeds at the new host), and link tickets travel verbatim, so personalized links already sitting in sent emails keep identifying their contacts. A form's campaign link is dropped on import when campaigns stay behind.
|
||||
@@ -16,6 +16,7 @@
|
||||
"---Contacts and inbox---",
|
||||
"contacts-crm",
|
||||
"segments",
|
||||
"forms",
|
||||
"website-tracking",
|
||||
"unibox",
|
||||
"meetings",
|
||||
|
||||
@@ -14,7 +14,7 @@ The data is split into groups. Every export includes **Workspace**; the rest are
|
||||
| Group | Contents |
|
||||
|-------|----------|
|
||||
| Workspace | The organization, members, roles, teams, mailboxes, API keys, webhooks, and settings, including the website tracking site key. Always included |
|
||||
| Contacts | Contacts, categories, segments with their manual overrides, notes, activities, and the suppression list |
|
||||
| Contacts | Contacts, categories, segments with their manual overrides, forms with their images, submissions, personalized link tickets and funnel events, notes, activities, and the suppression list |
|
||||
| Campaigns | Campaigns, sequences, senders, linked segments, attachments, and per-campaign settings |
|
||||
| CRM | Pipelines, deals, tasks, and meeting bookings |
|
||||
| Automations | Automations, connected integrations, and lead sync sources |
|
||||
@@ -100,6 +100,7 @@ An import runs as one transaction. If anything fails, nothing lands and the work
|
||||
|
||||
- **Reconnect any mailbox that needs it.** Mailboxes without credentials show as needing a reconnect in the mailbox list.
|
||||
- **Point your tracking domain at the new instance.** Click links already delivered keep resolving as long as the domain follows.
|
||||
- **Repoint your forms domain too.** A custom forms domain travels with the archive, but its verification does not: the record still points at the old instance. Update the `CNAME`, and the hourly re-check picks it up. Until then form links fall back to the shared host rather than breaking.
|
||||
- **Check campaign schedules.** Per-contact progress travels, so a running campaign resumes at the step it reached rather than restarting.
|
||||
- **Expect the daily send counters to be honoured.** Today's counts come across, so a mailbox cannot double its volume by being migrated mid-day.
|
||||
- **Re-authorize integrations** if you exported without credentials.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
@@ -0,0 +1,36 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
// Same shape as web/eslint.config.js, without that tree's legacy-code
|
||||
// downgrades: this tree starts clean, so the strict defaults stay errors.
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'node_modules', '**/._*']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<!-- The Go shell stamps the render token here; empty in `vite dev`. -->
|
||||
<meta name="wf-token" content="" />
|
||||
<title>Form</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "warmbly-forms",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-form": "^1.23.8",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-router": "^1.132.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.22",
|
||||
"globals": "^16.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vite": "^7.3.6"
|
||||
}
|
||||
}
|
||||
Generated
+2232
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
# Trust the install-time build scripts of these packages so pnpm doesn't
|
||||
# refuse to run them. esbuild is pulled in transitively by vite and needs
|
||||
# to compile its native binary at install time.
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -0,0 +1,116 @@
|
||||
// The /f/$publicId route: fetch the form (with any personalized ?t= ticket),
|
||||
// paint its theme, run the embed plumbing and funnel beacons, render the
|
||||
// chosen layout. The Go service already 404s unknown ids at the shell, so the
|
||||
// in-app not-found path only fires when a form unpublishes mid-visit.
|
||||
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
|
||||
import { fetchForm, FormNotFoundError, personalToken, StalePageError } from "./api";
|
||||
import { applyDesign, resolveDesign, splitPages } from "./design";
|
||||
import { isEmbedded, startResizeReporting } from "./embed";
|
||||
import { makeTracker } from "./events";
|
||||
import { FormRenderer } from "./FormRenderer";
|
||||
import { NotFound, StalePage, Unavailable } from "./NotFound";
|
||||
|
||||
export function FormPage() {
|
||||
const { publicId } = useParams({ from: "/f/$publicId" });
|
||||
const linkToken = personalToken();
|
||||
const query = useQuery({
|
||||
queryKey: ["form", publicId, linkToken],
|
||||
queryFn: () => fetchForm(publicId, linkToken),
|
||||
staleTime: Infinity,
|
||||
retry: (count, err) =>
|
||||
!(err instanceof FormNotFoundError) && !(err instanceof StalePageError) && count < 2,
|
||||
});
|
||||
|
||||
const form = query.data;
|
||||
const design = useMemo(() => (form ? resolveDesign(form.design) : null), [form]);
|
||||
const tracker = useMemo(
|
||||
() => (form ? makeTracker(form.public_id, splitPages(form.fields).length) : null),
|
||||
[form],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form || !design || !tracker) return;
|
||||
document.title = form.name;
|
||||
applyDesign(design);
|
||||
if (isEmbedded()) document.body.classList.add("embed");
|
||||
tracker.view();
|
||||
tracker.page(0);
|
||||
return startResizeReporting(form.public_id);
|
||||
}, [form, design, tracker]);
|
||||
|
||||
if (query.isPending) return null;
|
||||
if (query.isError) {
|
||||
if (query.error instanceof StalePageError) return <StalePage />;
|
||||
return query.error instanceof FormNotFoundError ? <NotFound /> : <Unavailable />;
|
||||
}
|
||||
if (!form || !design || !tracker) return null;
|
||||
|
||||
const logo = form.logo_url ? <img className="wf-logo" src={form.logo_url} alt="" /> : null;
|
||||
// The header only claims the logo when it is set to show one; otherwise the
|
||||
// logo keeps its own placement and the header carries just the title.
|
||||
const headerLogo = design.headerEnabled && design.headerShowLogo ? logo : null;
|
||||
const bodyLogo = headerLogo ? null : logo;
|
||||
// A header with neither a logo nor a title is an empty strip, so the live
|
||||
// page skips it; the builder shows a placeholder in its place instead.
|
||||
const headerEl =
|
||||
design.headerEnabled && (headerLogo || design.headerTitle) ? (
|
||||
<header
|
||||
className={[
|
||||
"wf-header",
|
||||
`h-${design.headerAlign}`,
|
||||
design.headerPlacement === "inline" ? "wf-header--inline" : "",
|
||||
design.headerSticky ? "is-sticky" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{headerLogo}
|
||||
{design.headerTitle && <span className="wf-header-title">{design.headerTitle}</span>}
|
||||
</header>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`wf layout-${design.layout} mode-${design.mode} align-${design.align}`}
|
||||
// The background image is a form asset like the logo, so it is set
|
||||
// here rather than carried in the design variables.
|
||||
style={
|
||||
form.background_url
|
||||
? ({ "--wf-bg-image": `url(${JSON.stringify(form.background_url)})` } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{design.headerPlacement === "page" && headerEl}
|
||||
<div className="wf-body">
|
||||
{design.layout === "split" && (
|
||||
<aside
|
||||
className="wf-cover"
|
||||
style={form.cover_url ? { backgroundImage: `url(${JSON.stringify(form.cover_url)})` } : undefined}
|
||||
>
|
||||
{bodyLogo}
|
||||
{design.coverTitle && <h2 className="wf-cover-title">{design.coverTitle}</h2>}
|
||||
{design.coverSubtitle && <p className="wf-cover-sub">{design.coverSubtitle}</p>}
|
||||
</aside>
|
||||
)}
|
||||
<main className="wf-main">
|
||||
{design.layout !== "split" && design.logoOnPage && bodyLogo}
|
||||
<div className={design.layout === "card" ? "card" : "wf-bare"}>
|
||||
{design.headerPlacement === "inline" && headerEl}
|
||||
{design.layout !== "split" && !design.logoOnPage && bodyLogo}
|
||||
<FormRenderer form={form} design={design} tracker={tracker} />
|
||||
</div>
|
||||
<div className="brand">
|
||||
<a href="https://warmbly.com" target="_blank" rel="noopener noreferrer">
|
||||
Powered by Warmbly
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// The live form: multi-page (or focus-mode) rendering with per-screen
|
||||
// validation, field state via TanStack Form, submission to the same-origin
|
||||
// /api. Client checks are a courtesy; the backend re-validates everything and
|
||||
// its message wins the error box.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
|
||||
import type { FormField, PublicForm } from "./api";
|
||||
import { personalToken, submitForm, StalePageError, SubmitRejectedError } from "./api";
|
||||
import type { ResolvedDesign } from "./design";
|
||||
import { focusSteps, pageIndexOf, splitPages } from "./design";
|
||||
import { postSubmitted, redirect } from "./embed";
|
||||
import type { Tracker } from "./events";
|
||||
import { visitorKey } from "./events";
|
||||
import type { AnswerValue } from "./fields";
|
||||
import { FieldControl } from "./fields";
|
||||
import { Turnstile } from "./Turnstile";
|
||||
import { resetTurnstile } from "./turnstile";
|
||||
|
||||
type Answers = Record<string, AnswerValue>;
|
||||
|
||||
const LAYOUT_TYPES = new Set(["heading", "paragraph", "divider", "hidden", "page_break"]);
|
||||
|
||||
function isInput(f: FormField): boolean {
|
||||
return !LAYOUT_TYPES.has(f.type);
|
||||
}
|
||||
|
||||
function prefillValue(f: FormField, raw: string): AnswerValue | undefined {
|
||||
switch (f.type) {
|
||||
case "checkbox":
|
||||
return ["yes", "true", "1", "on"].includes(raw.toLowerCase()) ? true : undefined;
|
||||
case "checkboxes": {
|
||||
const opts = new Set(f.options ?? []);
|
||||
const picked = raw.split(", ").filter((v) => opts.has(v));
|
||||
return picked.length > 0 ? picked : undefined;
|
||||
}
|
||||
case "select":
|
||||
case "radio":
|
||||
return (f.options ?? []).includes(raw) ? raw : undefined;
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultsFor(fields: FormField[], prefill?: Record<string, string>): Answers {
|
||||
const out: Answers = {};
|
||||
for (const f of fields) {
|
||||
if (!isInput(f)) continue;
|
||||
out[f.id] = f.type === "checkboxes" ? [] : f.type === "checkbox" ? false : "";
|
||||
const raw = prefill?.[f.id];
|
||||
if (raw) {
|
||||
const v = prefillValue(f, raw);
|
||||
if (v !== undefined) out[f.id] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function validateField(f: FormField, v: AnswerValue): string | undefined {
|
||||
const empty =
|
||||
f.type === "checkboxes" ? !Array.isArray(v) || v.length === 0 : f.type === "checkbox" ? v !== true : typeof v !== "string" || v.trim() === "";
|
||||
if (f.required && empty) return `${f.label || "This field"} is required.`;
|
||||
if (f.type === "email" && typeof v === "string" && v.trim() !== "" && !/^\S+@\S+\.\S+$/.test(v.trim())) {
|
||||
return "Enter a valid email address.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildAnswers(fields: FormField[], value: Answers): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {};
|
||||
for (const f of fields) {
|
||||
if (f.type === "hidden") {
|
||||
if (f.value) out[f.id] = [f.value];
|
||||
continue;
|
||||
}
|
||||
if (!isInput(f)) continue;
|
||||
const v = value[f.id];
|
||||
if (f.type === "checkboxes") {
|
||||
if (Array.isArray(v) && v.length > 0) out[f.id] = v;
|
||||
} else if (f.type === "checkbox") {
|
||||
if (v === true) out[f.id] = ["yes"];
|
||||
} else if (typeof v === "string" && v.trim() !== "") {
|
||||
out[f.id] = [v];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function SuccessBlock({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="ok">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="m9 12 2 2 4-4"></path>
|
||||
</svg>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Screen {
|
||||
fields: FormField[];
|
||||
title: string;
|
||||
/** The classic page this screen belongs to, for funnel reporting. */
|
||||
pageIndex: number;
|
||||
}
|
||||
|
||||
// buildScreens flattens the two modes into one shape: classic mode shows a
|
||||
// page of fields per screen, focus mode one question per screen.
|
||||
function buildScreens(fields: FormField[], mode: string): Screen[] {
|
||||
const pageOf = pageIndexOf(fields);
|
||||
if (mode === "focus") {
|
||||
return focusSteps(fields).map((step) => {
|
||||
const anchor = step[step.length - 1];
|
||||
return { fields: step, title: "", pageIndex: pageOf[anchor.id] ?? 0 };
|
||||
});
|
||||
}
|
||||
return splitPages(fields).map((p, i) => ({ fields: p.fields, title: p.title, pageIndex: i }));
|
||||
}
|
||||
|
||||
export function FormRenderer({
|
||||
form: def,
|
||||
design,
|
||||
tracker,
|
||||
}: {
|
||||
form: PublicForm;
|
||||
design: ResolvedDesign;
|
||||
tracker: Tracker;
|
||||
}) {
|
||||
// Bots submit near-instantly after render; the backend discards those.
|
||||
const renderedAt = useRef(Math.floor(Date.now() / 1000));
|
||||
const honeypot = useRef<HTMLInputElement>(null);
|
||||
const stepRef = useRef<HTMLDivElement>(null);
|
||||
const [captchaToken, setCaptchaToken] = useState("");
|
||||
const [serverError, setServerError] = useState("");
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
const [screen, setScreen] = useState(0);
|
||||
const [dir, setDir] = useState<"fwd" | "back">("fwd");
|
||||
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const screens = buildScreens(def.fields, design.mode);
|
||||
const current = screens[Math.min(screen, screens.length - 1)];
|
||||
const isLast = screen >= screens.length - 1;
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: defaultsFor(def.fields, def.prefill),
|
||||
onSubmit: async ({ value }) => {
|
||||
setServerError("");
|
||||
try {
|
||||
const res = await submitForm(def.public_id, {
|
||||
answers: buildAnswers(def.fields, value),
|
||||
website: honeypot.current?.value ?? "",
|
||||
_wt: renderedAt.current,
|
||||
captcha_token: captchaToken || undefined,
|
||||
source_url: document.referrer || window.location.href,
|
||||
link_token: personalToken() ?? undefined,
|
||||
visitor_key: visitorKey(),
|
||||
});
|
||||
postSubmitted(def.public_id);
|
||||
if (res.redirect_url) {
|
||||
redirect(res.redirect_url);
|
||||
return;
|
||||
}
|
||||
setDone(res.message || "Thanks!");
|
||||
} catch (e) {
|
||||
if (e instanceof StalePageError) {
|
||||
setServerError("This page has been open for a while. Refresh it and try again.");
|
||||
} else {
|
||||
setServerError(e instanceof SubmitRejectedError ? e.message : "Something went wrong. Try again.");
|
||||
}
|
||||
resetTurnstile();
|
||||
setCaptchaToken("");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Focus mode puts the visitor straight into the question.
|
||||
useEffect(() => {
|
||||
if (design.mode !== "focus") return;
|
||||
const el = stepRef.current?.querySelector<HTMLElement>("input:not([type=hidden]), select, textarea");
|
||||
el?.focus();
|
||||
}, [screen, design.mode]);
|
||||
|
||||
if (done !== null) return <SuccessBlock message={done} />;
|
||||
|
||||
// validateScreen is the only client validation: every screen is checked on
|
||||
// the way forward, so the final submit only ever re-checks the last one.
|
||||
const validateScreen = (s: Screen): boolean => {
|
||||
const errs: Record<string, string> = {};
|
||||
for (const f of s.fields) {
|
||||
if (!isInput(f)) continue;
|
||||
const msg = validateField(f, form.state.values[f.id]);
|
||||
if (msg) errs[f.id] = msg;
|
||||
}
|
||||
setScreenErrors(errs);
|
||||
return Object.keys(errs).length === 0;
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (!validateScreen(current) || isLast) return;
|
||||
setDir("fwd");
|
||||
const next = screen + 1;
|
||||
setScreen(next);
|
||||
tracker.page(screens[next].pageIndex);
|
||||
stepRef.current?.closest(".wf-main")?.scrollTo?.(0, 0);
|
||||
window.scrollTo(0, 0);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (screen === 0) return;
|
||||
setScreenErrors({});
|
||||
setDir("back");
|
||||
setScreen(screen - 1);
|
||||
};
|
||||
|
||||
const submitCurrent = () => {
|
||||
if (!validateScreen(current)) return;
|
||||
void form.handleSubmit();
|
||||
};
|
||||
|
||||
const showProgress = design.showProgress && screens.length > 1;
|
||||
const focusMode = design.mode === "focus";
|
||||
|
||||
return (
|
||||
<form
|
||||
noValidate
|
||||
onFocusCapture={() => tracker.start()}
|
||||
onChangeCapture={() => tracker.start()}
|
||||
onKeyDown={(e) => {
|
||||
if (!focusMode || e.key !== "Enter" || e.shiftKey) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target instanceof HTMLTextAreaElement) return;
|
||||
e.preventDefault();
|
||||
if (isLast) submitCurrent();
|
||||
else goNext();
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
submitCurrent();
|
||||
}}
|
||||
>
|
||||
<div className="hpwrap" aria-hidden="true">
|
||||
<input ref={honeypot} type="text" name="website" tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
{showProgress && (
|
||||
<div className="wf-progress" role="presentation">
|
||||
<span style={{ width: `${((screen + 1) / screens.length) * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div key={screen} ref={stepRef} className="wf-step" data-dir={dir}>
|
||||
<div className="grid">
|
||||
{serverError && <div className="err">{serverError}</div>}
|
||||
{!focusMode && current.title && <p className="wf-pagetitle">{current.title}</p>}
|
||||
{current.fields.map((f) => {
|
||||
switch (f.type) {
|
||||
case "heading":
|
||||
return (
|
||||
<div key={f.id} className="fld">
|
||||
<h2 className="h">{f.label}</h2>
|
||||
</div>
|
||||
);
|
||||
case "paragraph":
|
||||
return (
|
||||
<div key={f.id} className="fld">
|
||||
<p className="p">{f.value}</p>
|
||||
</div>
|
||||
);
|
||||
case "divider":
|
||||
return <hr key={f.id} className="d" />;
|
||||
case "hidden":
|
||||
case "page_break":
|
||||
return null;
|
||||
default:
|
||||
return (
|
||||
<form.Field key={f.id} name={f.id}>
|
||||
{(field) => (
|
||||
<div className={f.width === "half" ? "fld half" : "fld"}>
|
||||
{f.type !== "checkbox" && (
|
||||
<label className="l" htmlFor={`f-${f.id}`}>
|
||||
{f.label}
|
||||
{f.required && <span className="req"> *</span>}
|
||||
</label>
|
||||
)}
|
||||
<FieldControl
|
||||
field={f}
|
||||
value={field.state.value}
|
||||
onChange={(v) => {
|
||||
field.handleChange(v);
|
||||
if (screenErrors[f.id]) {
|
||||
setScreenErrors((prev) => {
|
||||
const rest = { ...prev };
|
||||
delete rest[f.id];
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
/>
|
||||
{screenErrors[f.id] && <p className="fielderr">{screenErrors[f.id]}</p>}
|
||||
{f.help_text && <p className="help">{f.help_text}</p>}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{isLast && def.captcha_site_key && (
|
||||
<Turnstile siteKey={def.captcha_site_key} onToken={setCaptchaToken} />
|
||||
)}
|
||||
{isLast ? (
|
||||
<div className={screen > 0 ? "wf-pagenav" : "btnrow"}>
|
||||
{screen > 0 && (
|
||||
<button type="button" className="wf-back" onClick={goBack}>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<form.Subscribe selector={(s) => s.isSubmitting}>
|
||||
{(isSubmitting) => (
|
||||
<button
|
||||
className={design.btnFullWidth ? "submit full" : "submit"}
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{design.btnLabel}
|
||||
</button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</div>
|
||||
) : (
|
||||
<div className="wf-pagenav">
|
||||
{screen > 0 ? (
|
||||
<button type="button" className="wf-back" onClick={goBack}>
|
||||
Back
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button type="button" className="submit" onClick={goNext}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{focusMode && <p className="wf-hint">Press Enter to continue</p>}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Terminal states, word-for-word with the static pages the Go service serves
|
||||
// for the same conditions at the shell level.
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div className="plain">
|
||||
<h1>This form is no longer available</h1>
|
||||
<p>It may have been unpublished or removed.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Unavailable() {
|
||||
return (
|
||||
<div className="plain">
|
||||
<h1>This form is temporarily unavailable</h1>
|
||||
<p>Please try again in a moment.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// StalePage: the tab outlived its render token (12h); only a reload mints a
|
||||
// new one.
|
||||
export function StalePage() {
|
||||
return (
|
||||
<div className="plain">
|
||||
<h1>This page went stale</h1>
|
||||
<p>It has been open for a while. Refresh to continue.</p>
|
||||
<button type="button" onClick={() => window.location.reload()}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Cloudflare Turnstile widget, rendered explicitly so React owns the mount
|
||||
// point. resetTurnstile() (turnstile.ts) clears the used token after a
|
||||
// rejected submit.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { loadTurnstileScript } from "./turnstile";
|
||||
|
||||
export function Turnstile({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) {
|
||||
const el = useRef<HTMLDivElement>(null);
|
||||
const onTokenRef = useRef(onToken);
|
||||
onTokenRef.current = onToken;
|
||||
|
||||
useEffect(() => {
|
||||
let widgetId: string | null = null;
|
||||
let cancelled = false;
|
||||
loadTurnstileScript()
|
||||
.then(() => {
|
||||
if (cancelled || !el.current || !window.turnstile) return;
|
||||
widgetId = window.turnstile.render(el.current, {
|
||||
sitekey: siteKey,
|
||||
callback: (token: string) => onTokenRef.current(token),
|
||||
"expired-callback": () => onTokenRef.current(""),
|
||||
"error-callback": () => onTokenRef.current(""),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// widget stays absent; the backend rejects with a clear message
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (widgetId) {
|
||||
try {
|
||||
window.turnstile?.remove(widgetId);
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [siteKey]);
|
||||
|
||||
return (
|
||||
<div className="fld">
|
||||
<div ref={el} className="cf-turnstile" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// The same-origin JSON API the Go forms service (cmd/forms) exposes for this
|
||||
// app. Mirrors internal/formserver: keep the shapes in sync with its
|
||||
// public handlers.
|
||||
|
||||
export type FormFieldType =
|
||||
| "text"
|
||||
| "email"
|
||||
| "phone"
|
||||
| "textarea"
|
||||
| "number"
|
||||
| "select"
|
||||
| "radio"
|
||||
| "checkboxes"
|
||||
| "checkbox"
|
||||
| "date"
|
||||
| "hidden"
|
||||
| "heading"
|
||||
| "paragraph"
|
||||
| "divider"
|
||||
| "page_break";
|
||||
|
||||
export interface FormField {
|
||||
id: string;
|
||||
type: FormFieldType;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help_text?: string;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
/** Hidden-field constant or paragraph body. */
|
||||
value?: string;
|
||||
/** "full" (default) or "half"; two half fields share a row. */
|
||||
width?: string;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export interface FormDesign {
|
||||
font_family?: string;
|
||||
page_background?: string;
|
||||
form_background?: string;
|
||||
text_color?: string;
|
||||
label_color?: string;
|
||||
input_background?: string;
|
||||
input_border_color?: string;
|
||||
input_text_color?: string;
|
||||
placeholder_color?: string;
|
||||
accent_color?: string;
|
||||
button_background?: string;
|
||||
button_text_color?: string;
|
||||
button_text?: string;
|
||||
button_size?: string;
|
||||
button_full_width?: boolean;
|
||||
border_radius?: number;
|
||||
max_width?: number;
|
||||
spacing?: string;
|
||||
shadow?: boolean;
|
||||
theme?: string;
|
||||
layout?: string;
|
||||
mode?: string;
|
||||
page_background_end?: string;
|
||||
align?: string;
|
||||
show_progress?: boolean;
|
||||
}
|
||||
|
||||
export interface PublicForm {
|
||||
public_id: string;
|
||||
name: string;
|
||||
fields: FormField[];
|
||||
design: FormDesign;
|
||||
logo_url?: string;
|
||||
cover_url?: string;
|
||||
background_url?: string;
|
||||
captcha_site_key?: string;
|
||||
/** Present only when a valid personalized ?t= link opened the page. */
|
||||
prefill?: Record<string, string>;
|
||||
link_token?: string;
|
||||
}
|
||||
|
||||
export interface SubmitPayload {
|
||||
answers: Record<string, string[]>;
|
||||
/** Honeypot value; a human never fills it. */
|
||||
website: string;
|
||||
/** Unix second the page was rendered; bots submit near-instantly. */
|
||||
_wt: number;
|
||||
captcha_token?: string;
|
||||
source_url?: string;
|
||||
/** Personalized link ticket; attributes the submission to a contact. */
|
||||
link_token?: string;
|
||||
visitor_key?: string;
|
||||
}
|
||||
|
||||
export interface SubmitResult {
|
||||
message: string;
|
||||
redirect_url?: string;
|
||||
}
|
||||
|
||||
export class FormNotFoundError extends Error {
|
||||
constructor() {
|
||||
super("form not found");
|
||||
this.name = "FormNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SubmitRejectedError extends Error {}
|
||||
|
||||
/** The tab outlived its render token; only a reload can mint a new one. */
|
||||
export class StalePageError extends Error {
|
||||
constructor() {
|
||||
super("stale page");
|
||||
this.name = "StalePageError";
|
||||
}
|
||||
}
|
||||
|
||||
// The Go shell stamps the render token into this meta tag; every API call
|
||||
// must carry it, so a script that never loaded the page gets nothing.
|
||||
const renderToken =
|
||||
document.querySelector('meta[name="wf-token"]')?.getAttribute("content") ?? "";
|
||||
|
||||
export function apiHeaders(): Record<string, string> {
|
||||
return { Accept: "application/json", "X-Warmbly-Render": renderToken };
|
||||
}
|
||||
|
||||
// personalToken reads the ?t= ticket from a personalized link.
|
||||
export function personalToken(): string | null {
|
||||
const t = new URLSearchParams(window.location.search).get("t");
|
||||
return t && /^[A-Za-z0-9-]{1,64}$/.test(t) ? t : null;
|
||||
}
|
||||
|
||||
async function throwForStatus(res: Response): Promise<never> {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string; message?: string } | null;
|
||||
if (res.status === 403 && body?.error === "stale_page") throw new StalePageError();
|
||||
if (res.status === 404) throw new FormNotFoundError();
|
||||
throw new Error(`request failed (${res.status})`);
|
||||
}
|
||||
|
||||
export async function fetchForm(publicId: string, linkToken?: string | null): Promise<PublicForm> {
|
||||
const qs = linkToken ? `?t=${encodeURIComponent(linkToken)}` : "";
|
||||
const res = await fetch(`/api/forms/${encodeURIComponent(publicId)}${qs}`, {
|
||||
headers: apiHeaders(),
|
||||
});
|
||||
if (!res.ok) await throwForStatus(res);
|
||||
return (await res.json()) as PublicForm;
|
||||
}
|
||||
|
||||
export async function submitForm(publicId: string, payload: SubmitPayload): Promise<SubmitResult> {
|
||||
const res = await fetch(`/api/forms/${encodeURIComponent(publicId)}/submit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...apiHeaders() },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
message?: string;
|
||||
} | null;
|
||||
if (res.status === 403 && body?.error === "stale_page") throw new StalePageError();
|
||||
if (res.status === 404) throw new FormNotFoundError();
|
||||
if (!res.ok) {
|
||||
throw new SubmitRejectedError(body?.message || "Something went wrong. Try again.");
|
||||
}
|
||||
return (body ?? { message: "Thanks!" }) as SubmitResult;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// The design engine lives in designCore.ts (mirrored with the builder
|
||||
// canvas); this file only paints a resolved design onto the document.
|
||||
|
||||
import { designVars, ensureFont, type ResolvedDesign } from "./designCore";
|
||||
|
||||
export {
|
||||
resolveDesign,
|
||||
splitPages,
|
||||
focusSteps,
|
||||
pageIndexOf,
|
||||
type ResolvedDesign,
|
||||
type FormPage,
|
||||
} from "./designCore";
|
||||
|
||||
// applyDesign paints the resolved theme onto the document as CSS custom
|
||||
// properties; form-theme.css reads nothing but these variables.
|
||||
export function applyDesign(r: ResolvedDesign) {
|
||||
const s = document.documentElement.style;
|
||||
for (const [name, value] of Object.entries(designVars(r))) {
|
||||
s.setProperty(name, value);
|
||||
}
|
||||
ensureFont(r);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// MIRROR — this file exists byte-identical in web/src/components/app/forms/
|
||||
// and forms/src/. Edit both copies together; scripts/check-forms-mirror.sh
|
||||
// (run by `make lint`) fails the build when they drift. It is self-contained
|
||||
// on purpose: the builder canvas and the hosted page must resolve a design
|
||||
// and split pages exactly the same way, and this file is that single truth.
|
||||
|
||||
export interface DesignInput {
|
||||
font_family?: string;
|
||||
page_background?: string;
|
||||
form_background?: string;
|
||||
text_color?: string;
|
||||
label_color?: string;
|
||||
input_background?: string;
|
||||
input_border_color?: string;
|
||||
input_text_color?: string;
|
||||
placeholder_color?: string;
|
||||
accent_color?: string;
|
||||
button_background?: string;
|
||||
button_text_color?: string;
|
||||
button_text?: string;
|
||||
button_size?: string;
|
||||
button_full_width?: boolean;
|
||||
border_radius?: number;
|
||||
max_width?: number;
|
||||
spacing?: string;
|
||||
shadow?: boolean;
|
||||
theme?: string;
|
||||
layout?: string;
|
||||
mode?: string;
|
||||
page_background_end?: string;
|
||||
align?: string;
|
||||
show_progress?: boolean;
|
||||
/** "cover" (fill), "contain" (fit) or "tile" (repeat). */
|
||||
background_size?: string;
|
||||
/** 0-100: how much the page color veils the image, for readable text. */
|
||||
background_overlay?: number;
|
||||
/** A bar carrying the logo and a title. */
|
||||
header_enabled?: boolean;
|
||||
header_title?: string;
|
||||
header_background?: string;
|
||||
/** "page" spans the viewport, "inline" sits with the form itself. */
|
||||
header_placement?: string;
|
||||
/** "left", "center" or "between"; defaults to the form's alignment. */
|
||||
header_align?: string;
|
||||
/** Keep a page-width header visible while the form scrolls. */
|
||||
header_sticky?: boolean;
|
||||
/** false leaves the logo in its own placement and gives the header the
|
||||
* title alone. Defaults to true. */
|
||||
header_show_logo?: boolean;
|
||||
/** Text over the Split layout's cover panel. */
|
||||
cover_title?: string;
|
||||
cover_subtitle?: string;
|
||||
/** "sm" | "md" | "lg": how tall the logo renders. */
|
||||
logo_size?: string;
|
||||
/** "card" sits it on the form surface, "page" above the card. */
|
||||
logo_position?: string;
|
||||
}
|
||||
|
||||
// CoreField is the structural slice of a form field the page math needs;
|
||||
// both trees' FormField types satisfy it.
|
||||
export interface CoreField {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type FormLayout = "card" | "wide" | "split";
|
||||
export type FormMode = "classic" | "focus";
|
||||
export type FormAlign = "left" | "center";
|
||||
export type FormBackgroundSize = "cover" | "contain" | "tile";
|
||||
export type HeaderPlacement = "page" | "inline";
|
||||
export type HeaderAlign = "left" | "center" | "between";
|
||||
|
||||
// Keep in sync with the font_family switch in internal/models/form.go.
|
||||
export const FONT_CATALOG: Record<string, { label: string; stack: string; google?: string }> = {
|
||||
system: { label: "System", stack: "system-ui,-apple-system,'Segoe UI',Roboto,sans-serif" },
|
||||
inter: {
|
||||
label: "Inter",
|
||||
stack: "'Inter',system-ui,-apple-system,'Segoe UI',sans-serif",
|
||||
google: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap",
|
||||
},
|
||||
serif: { label: "Serif", stack: "Georgia,'Times New Roman',serif" },
|
||||
mono: { label: "Mono", stack: "ui-monospace,SFMono-Regular,Menlo,monospace" },
|
||||
manrope: {
|
||||
label: "Manrope",
|
||||
stack: "'Manrope',system-ui,sans-serif",
|
||||
google: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap",
|
||||
},
|
||||
sora: {
|
||||
label: "Sora",
|
||||
stack: "'Sora',system-ui,sans-serif",
|
||||
google: "https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600&display=swap",
|
||||
},
|
||||
fraunces: {
|
||||
label: "Fraunces",
|
||||
stack: "'Fraunces',Georgia,serif",
|
||||
google: "https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&display=swap",
|
||||
},
|
||||
"space-grotesk": {
|
||||
label: "Space Grotesk",
|
||||
stack: "'Space Grotesk',system-ui,sans-serif",
|
||||
google: "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&display=swap",
|
||||
},
|
||||
};
|
||||
|
||||
export interface ResolvedDesign {
|
||||
font: string;
|
||||
fontStack: string;
|
||||
googleFontUrl: string | null;
|
||||
pageBg: string;
|
||||
pageBg2: string | null;
|
||||
/** Solid color, or a vertical gradient when page_background_end is set. */
|
||||
pageBgCss: string;
|
||||
formBg: string;
|
||||
text: string;
|
||||
label: string;
|
||||
inputBg: string;
|
||||
inputBorder: string;
|
||||
inputText: string;
|
||||
placeholder: string;
|
||||
accent: string;
|
||||
btnBg: string;
|
||||
btnText: string;
|
||||
btnLabel: string;
|
||||
btnPad: string;
|
||||
btnFont: number;
|
||||
btnFullWidth: boolean;
|
||||
radius: number;
|
||||
inputRadius: number;
|
||||
maxWidth: number;
|
||||
gap: number;
|
||||
shadow: boolean;
|
||||
layout: FormLayout;
|
||||
mode: FormMode;
|
||||
align: FormAlign;
|
||||
showProgress: boolean;
|
||||
bgSize: FormBackgroundSize;
|
||||
/** 0..1, already normalized from the 0-100 the editor stores. */
|
||||
bgOverlay: number;
|
||||
headerEnabled: boolean;
|
||||
headerTitle: string;
|
||||
headerBg: string;
|
||||
headerPlacement: HeaderPlacement;
|
||||
headerAlign: HeaderAlign;
|
||||
headerSticky: boolean;
|
||||
headerShowLogo: boolean;
|
||||
coverTitle: string;
|
||||
coverSubtitle: string;
|
||||
logoHeight: number;
|
||||
/** true renders the logo above the card, on the page background. */
|
||||
logoOnPage: boolean;
|
||||
}
|
||||
|
||||
export function resolveDesign(d: DesignInput): ResolvedDesign {
|
||||
const font = d.font_family && FONT_CATALOG[d.font_family] ? d.font_family : "system";
|
||||
const radius = d.border_radius ?? 10;
|
||||
const btnSizes: Record<string, [string, number]> = {
|
||||
sm: ["8px 16px", 13],
|
||||
md: ["10px 20px", 14],
|
||||
lg: ["13px 26px", 16],
|
||||
};
|
||||
const [btnPad, btnFont] = btnSizes[d.button_size ?? "md"] ?? btnSizes.md;
|
||||
const pageBg = d.page_background || "#f8fafc";
|
||||
const pageBg2 = d.page_background_end || null;
|
||||
const layout: FormLayout = d.layout === "wide" || d.layout === "split" ? d.layout : "card";
|
||||
return {
|
||||
font,
|
||||
fontStack: FONT_CATALOG[font].stack,
|
||||
googleFontUrl: FONT_CATALOG[font].google ?? null,
|
||||
pageBg,
|
||||
pageBg2,
|
||||
pageBgCss: pageBg2 ? `linear-gradient(180deg, ${pageBg}, ${pageBg2})` : pageBg,
|
||||
formBg: d.form_background || "#ffffff",
|
||||
text: d.text_color || "#0f172a",
|
||||
label: d.label_color || "#334155",
|
||||
inputBg: d.input_background || "#ffffff",
|
||||
inputBorder: d.input_border_color || "#e2e8f0",
|
||||
inputText: d.input_text_color || "#0f172a",
|
||||
placeholder: d.placeholder_color || "#94a3b8",
|
||||
accent: d.accent_color || "#0284c7",
|
||||
btnBg: d.button_background || "#0284c7",
|
||||
btnText: d.button_text_color || "#ffffff",
|
||||
btnLabel: d.button_text || "Submit",
|
||||
btnPad,
|
||||
btnFont,
|
||||
btnFullWidth: !!d.button_full_width,
|
||||
radius,
|
||||
inputRadius: Math.min(radius, 12),
|
||||
maxWidth: d.max_width ?? 560,
|
||||
gap: d.spacing === "compact" ? 10 : d.spacing === "relaxed" ? 22 : 16,
|
||||
// Only the card layout carries a box that can cast a shadow.
|
||||
shadow: (d.shadow ?? true) && layout === "card",
|
||||
layout,
|
||||
mode: d.mode === "focus" ? "focus" : "classic",
|
||||
align: d.align === "center" ? "center" : "left",
|
||||
showProgress: d.show_progress ?? true,
|
||||
bgSize: d.background_size === "contain" || d.background_size === "tile" ? d.background_size : "cover",
|
||||
// Clamped here so a bad stored value can never wash the page out.
|
||||
bgOverlay: Math.min(100, Math.max(0, d.background_overlay ?? 0)) / 100,
|
||||
headerEnabled: !!d.header_enabled,
|
||||
headerTitle: d.header_title || "",
|
||||
headerPlacement: d.header_placement === "inline" ? "inline" : "page",
|
||||
// Unset follows the form's own alignment, so a centered form gets a
|
||||
// centered header without a second decision.
|
||||
headerAlign:
|
||||
d.header_align === "left" || d.header_align === "center" || d.header_align === "between"
|
||||
? d.header_align
|
||||
: d.align === "center"
|
||||
? "center"
|
||||
: "left",
|
||||
// Sticky only means anything for a page-width bar.
|
||||
headerSticky: !!d.header_sticky && d.header_placement !== "inline",
|
||||
headerShowLogo: d.header_show_logo ?? true,
|
||||
// The header sits on the form surface by default so it reads as chrome
|
||||
// rather than a second background.
|
||||
headerBg: d.header_background || d.form_background || "#ffffff",
|
||||
coverTitle: d.cover_title || "",
|
||||
coverSubtitle: d.cover_subtitle || "",
|
||||
logoHeight: { sm: 24, md: 36, lg: 52 }[d.logo_size ?? "md"] ?? 36,
|
||||
// Only the card layout has a box to sit above; wide and split put the
|
||||
// logo on the form surface either way.
|
||||
logoOnPage: d.logo_position === "page" && (d.layout ?? "card") === "card",
|
||||
};
|
||||
}
|
||||
|
||||
// designVars is the full --wf-* variable map form-theme.css reads. The hosted
|
||||
// page paints it onto documentElement, the builder canvas spreads it as an
|
||||
// inline style; the shared name list is what keeps the two from drifting.
|
||||
export function designVars(r: ResolvedDesign): Record<string, string> {
|
||||
return {
|
||||
"--wf-font": r.fontStack,
|
||||
"--wf-page-bg": r.pageBg,
|
||||
"--wf-page-bg-css": r.pageBgCss,
|
||||
"--wf-form-bg": r.formBg,
|
||||
"--wf-text": r.text,
|
||||
"--wf-label": r.label,
|
||||
"--wf-input-bg": r.inputBg,
|
||||
"--wf-input-border": r.inputBorder,
|
||||
"--wf-input-text": r.inputText,
|
||||
"--wf-placeholder": r.placeholder,
|
||||
"--wf-accent": r.accent,
|
||||
"--wf-btn-bg": r.btnBg,
|
||||
"--wf-btn-text": r.btnText,
|
||||
"--wf-btn-pad": r.btnPad,
|
||||
"--wf-btn-font": `${r.btnFont}px`,
|
||||
"--wf-radius": `${r.radius}px`,
|
||||
"--wf-input-radius": `${r.inputRadius}px`,
|
||||
"--wf-max-width": `${r.maxWidth}px`,
|
||||
"--wf-gap": `${r.gap}px`,
|
||||
"--wf-shadow": r.shadow ? "0 1px 2px rgba(15,23,42,.06),0 8px 24px rgba(15,23,42,.08)" : "none",
|
||||
"--wf-bg-size": r.bgSize === "tile" ? "auto" : r.bgSize,
|
||||
"--wf-bg-repeat": r.bgSize === "tile" ? "repeat" : "no-repeat",
|
||||
"--wf-bg-overlay": String(r.bgOverlay),
|
||||
"--wf-header-bg": r.headerBg,
|
||||
"--wf-logo-h": `${r.logoHeight}px`,
|
||||
};
|
||||
}
|
||||
|
||||
// ensureFont loads the resolved Google font once per document.
|
||||
export function ensureFont(r: ResolvedDesign) {
|
||||
if (!r.googleFontUrl) return;
|
||||
const id = `wf-font-${r.font}`;
|
||||
if (document.getElementById(id)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
link.href = r.googleFontUrl;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
export interface FormPage<F extends CoreField> {
|
||||
title: string;
|
||||
fields: F[];
|
||||
}
|
||||
|
||||
const isVisibleInput = (f: CoreField) =>
|
||||
!["heading", "paragraph", "divider", "page_break", "hidden"].includes(f.type);
|
||||
|
||||
// splitPages cuts the flat field list on page_break blocks. The break's label
|
||||
// titles the page it OPENS; pages left with no fields at all are dropped so a
|
||||
// stray leading or trailing break cannot render an empty screen.
|
||||
export function splitPages<F extends CoreField>(fields: F[]): FormPage<F>[] {
|
||||
const pages: FormPage<F>[] = [{ title: "", fields: [] }];
|
||||
for (const f of fields) {
|
||||
if (f.type === "page_break") {
|
||||
pages.push({ title: f.label ?? "", fields: [] });
|
||||
} else {
|
||||
pages[pages.length - 1].fields.push(f);
|
||||
}
|
||||
}
|
||||
const filled = pages.filter((p) => p.fields.length > 0);
|
||||
return filled.length > 0 ? filled : [pages[0]];
|
||||
}
|
||||
|
||||
// focusSteps groups the list for focus mode: every visible input anchors one
|
||||
// screen, carrying the layout blocks and hidden fields written before it;
|
||||
// trailing blocks join the last screen. Page breaks are boundaries only.
|
||||
export function focusSteps<F extends CoreField>(fields: F[]): F[][] {
|
||||
const steps: F[][] = [];
|
||||
let pending: F[] = [];
|
||||
for (const f of fields) {
|
||||
if (f.type === "page_break") continue;
|
||||
pending.push(f);
|
||||
if (isVisibleInput(f)) {
|
||||
steps.push(pending);
|
||||
pending = [];
|
||||
}
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
if (steps.length > 0) steps[steps.length - 1].push(...pending);
|
||||
else steps.push(pending);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
// pageIndexOf maps every field id to the classic page it lives on, so focus
|
||||
// mode can report funnel progress in page terms.
|
||||
export function pageIndexOf(fields: CoreField[]): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
let page = 0;
|
||||
for (const f of fields) {
|
||||
if (f.type === "page_break") {
|
||||
page += 1;
|
||||
} else {
|
||||
out[f.id] = page;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Embed plumbing: the hosted page runs inside an iframe the /forms.js loader
|
||||
// mounts, and reports its height so the frame never clips. Message names are
|
||||
// the contract with internal/formserver/static/forms-embed.js.
|
||||
|
||||
export function isEmbedded(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("embed") === "1";
|
||||
}
|
||||
|
||||
// startResizeReporting posts the document height to the parent on every size
|
||||
// change. No-op outside a frame. Returns a cleanup function.
|
||||
export function startResizeReporting(publicId: string): () => void {
|
||||
if (window.parent === window) return () => {};
|
||||
const post = () => {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{ type: "warmbly:resize", form: publicId, height: document.documentElement.scrollHeight },
|
||||
"*",
|
||||
);
|
||||
} catch {
|
||||
// sandboxed parents can refuse messages; the frame just keeps its height
|
||||
}
|
||||
};
|
||||
post();
|
||||
const ro = new ResizeObserver(post);
|
||||
ro.observe(document.body);
|
||||
return () => ro.disconnect();
|
||||
}
|
||||
|
||||
export function postSubmitted(publicId: string) {
|
||||
try {
|
||||
window.parent.postMessage({ type: "warmbly:submitted", form: publicId }, "*");
|
||||
} catch {
|
||||
// same as above
|
||||
}
|
||||
}
|
||||
|
||||
// redirect escapes the iframe when possible so the thank-you page fills the
|
||||
// tab, not a 480px frame.
|
||||
export function redirect(url: string) {
|
||||
try {
|
||||
if (window.top) {
|
||||
window.top.location.href = url;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// cross-origin top blocks assignment; fall through to the frame itself
|
||||
}
|
||||
window.location.href = url;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Funnel beacons: view on load, start on first interaction, page as the
|
||||
// visitor advances. Fire-and-forget; analytics must never slow the form or
|
||||
// surface an error. Submits are recorded server-side by the submit pipeline.
|
||||
|
||||
import { apiHeaders, personalToken } from "./api";
|
||||
|
||||
const VISITOR_KEY_STORAGE = "wf_vk";
|
||||
|
||||
let memoryKey: string | null = null;
|
||||
|
||||
// visitorKey is a random, non-identifying id that lets analytics tell "one
|
||||
// visitor saw four pages" from "four visitors". Storage can be blocked in
|
||||
// third-party iframes; fall back gracefully to a per-load key.
|
||||
export function visitorKey(): string {
|
||||
if (memoryKey) return memoryKey;
|
||||
const mint = () => crypto.randomUUID();
|
||||
try {
|
||||
let k = localStorage.getItem(VISITOR_KEY_STORAGE);
|
||||
if (!k) {
|
||||
k = mint();
|
||||
localStorage.setItem(VISITOR_KEY_STORAGE, k);
|
||||
}
|
||||
memoryKey = k;
|
||||
} catch {
|
||||
memoryKey = mint();
|
||||
}
|
||||
return memoryKey;
|
||||
}
|
||||
|
||||
interface EventPayload {
|
||||
type: "view" | "start" | "page";
|
||||
page_index: number;
|
||||
pages_total: number;
|
||||
}
|
||||
|
||||
function sendEvent(publicId: string, e: EventPayload) {
|
||||
// keepalive lets a beacon survive navigation; sendBeacon cannot carry the
|
||||
// render-token header, so plain fetch it is.
|
||||
void fetch(`/api/forms/${encodeURIComponent(publicId)}/events`, {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
headers: { "Content-Type": "application/json", ...apiHeaders() },
|
||||
body: JSON.stringify({
|
||||
...e,
|
||||
visitor_key: visitorKey(),
|
||||
source_url: document.referrer || window.location.href,
|
||||
link_token: personalToken() ?? undefined,
|
||||
}),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export interface Tracker {
|
||||
view(): void;
|
||||
start(): void;
|
||||
page(index: number): void;
|
||||
}
|
||||
|
||||
// makeTracker enforces the once-guards client-side (the server dedupes views
|
||||
// again by visitor key): one view per mount, one start, and page events only
|
||||
// when the visitor reaches a page they have not seen this session.
|
||||
export function makeTracker(publicId: string, pagesTotal: number): Tracker {
|
||||
let viewed = false;
|
||||
let started = false;
|
||||
let maxPage = -1;
|
||||
return {
|
||||
view() {
|
||||
if (viewed) return;
|
||||
viewed = true;
|
||||
sendEvent(publicId, { type: "view", page_index: 0, pages_total: pagesTotal });
|
||||
},
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
sendEvent(publicId, { type: "start", page_index: Math.max(maxPage, 0), pages_total: pagesTotal });
|
||||
},
|
||||
page(index: number) {
|
||||
if (index <= maxPage) return;
|
||||
maxPage = index;
|
||||
sendEvent(publicId, { type: "page", page_index: index, pages_total: pagesTotal });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// One controlled input per field type, class-for-class with the page
|
||||
// stylesheet. Layout blocks (heading, paragraph, divider) render in
|
||||
// FormRenderer; hidden fields never render at all.
|
||||
|
||||
import type { FormField } from "./api";
|
||||
|
||||
export type AnswerValue = string | string[] | boolean;
|
||||
|
||||
export function FieldControl({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
}: {
|
||||
field: FormField;
|
||||
value: AnswerValue;
|
||||
onChange: (v: AnswerValue) => void;
|
||||
onBlur: () => void;
|
||||
}) {
|
||||
const id = `f-${field.id}`;
|
||||
const str = typeof value === "string" ? value : "";
|
||||
|
||||
switch (field.type) {
|
||||
case "textarea":
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
rows={field.rows || 4}
|
||||
placeholder={field.placeholder}
|
||||
value={str}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<select id={id} value={str} onChange={(e) => onChange(e.target.value)} onBlur={onBlur}>
|
||||
<option value="">{field.placeholder || "Select…"}</option>
|
||||
{(field.options ?? []).map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
case "radio":
|
||||
return (
|
||||
<div className="opts">
|
||||
{(field.options ?? []).map((o) => (
|
||||
<label key={o} className="opt">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.id}
|
||||
value={o}
|
||||
checked={str === o}
|
||||
onChange={() => onChange(o)}
|
||||
onBlur={onBlur}
|
||||
/>{" "}
|
||||
{o}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "checkboxes": {
|
||||
const arr = Array.isArray(value) ? value : [];
|
||||
const toggle = (o: string) => onChange(arr.includes(o) ? arr.filter((x) => x !== o) : [...arr, o]);
|
||||
return (
|
||||
<div className="opts">
|
||||
{(field.options ?? []).map((o) => (
|
||||
<label key={o} className="opt">
|
||||
<input type="checkbox" checked={arr.includes(o)} onChange={() => toggle(o)} onBlur={onBlur} />{" "}
|
||||
{o}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "checkbox":
|
||||
return (
|
||||
<label className="opt">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={value === true}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
onBlur={onBlur}
|
||||
/>{" "}
|
||||
{field.placeholder}
|
||||
</label>
|
||||
);
|
||||
case "email":
|
||||
return (
|
||||
<input
|
||||
type="email"
|
||||
id={id}
|
||||
placeholder={field.placeholder}
|
||||
autoComplete="email"
|
||||
value={str}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
case "phone":
|
||||
return (
|
||||
<input
|
||||
type="tel"
|
||||
id={id}
|
||||
placeholder={field.placeholder}
|
||||
autoComplete="tel"
|
||||
value={str}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
id={id}
|
||||
placeholder={field.placeholder}
|
||||
value={str}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
case "date":
|
||||
return <input type="date" id={id} value={str} onChange={(e) => onChange(e.target.value)} onBlur={onBlur} />;
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
id={id}
|
||||
placeholder={field.placeholder}
|
||||
value={str}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
/* MIRROR — this file exists byte-identical in web/src/components/app/forms/
|
||||
and forms/src/. Edit both copies together; scripts/check-forms-mirror.sh
|
||||
(run by `make lint`) fails the build when they drift. Every rule is scoped
|
||||
under .wf so the builder canvas can import it without leaking into the
|
||||
dashboard, and every color and size comes from the --wf-* variables
|
||||
designCore.ts emits. */
|
||||
|
||||
.wf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--wf-font, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
|
||||
color: var(--wf-text, #0f172a);
|
||||
background: var(--wf-page-bg-css, var(--wf-page-bg, #f8fafc));
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* .wf-body holds the layout so an optional header can sit above it without
|
||||
every layout needing a variant. */
|
||||
.wf .wf-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.wf .wf-body > .wf-main {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.wf *,
|
||||
.wf *::before,
|
||||
.wf *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* A page background image sits over the color and under the content, with an
|
||||
overlay of the page color veiling it: without that, any light theme on a
|
||||
busy photo is unreadable. Both layers are inert to pointers. */
|
||||
.wf {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.wf::before,
|
||||
.wf::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wf::before {
|
||||
background-image: var(--wf-bg-image, none);
|
||||
background-size: var(--wf-bg-size, cover);
|
||||
background-repeat: var(--wf-bg-repeat, no-repeat);
|
||||
background-position: center;
|
||||
}
|
||||
.wf::after {
|
||||
background: var(--wf-page-bg-css, var(--wf-page-bg, #f8fafc));
|
||||
opacity: var(--wf-bg-overlay, 0);
|
||||
}
|
||||
/* Inside the builder canvas the shell is a scroll pane, not the viewport, so
|
||||
the layers have to be absolute or they would track the window instead. */
|
||||
.wf.wf-inline::before,
|
||||
.wf.wf-inline::after {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* ---- layouts ----
|
||||
The fields always sit on the form background: a theme picks its label,
|
||||
text and input colors against that surface, so a layout that drops them
|
||||
straight onto the page background makes dark-on-dark text. Card gets the
|
||||
surface from its box, Wide takes the whole viewport, Split takes the
|
||||
panel beside the cover. */
|
||||
.wf .wf-main {
|
||||
max-width: var(--wf-max-width, 560px);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
.wf.layout-wide {
|
||||
background: var(--wf-form-bg, #ffffff);
|
||||
}
|
||||
.wf.layout-wide .wf-main {
|
||||
padding: 56px 20px;
|
||||
}
|
||||
.wf.layout-split .wf-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 38%) 1fr;
|
||||
}
|
||||
.wf.layout-split .wf-cover {
|
||||
position: relative;
|
||||
background: var(--wf-page-bg-css, var(--wf-page-bg, #f8fafc));
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
padding: 40px 36px;
|
||||
}
|
||||
/* A wash of the accent keeps the cover from reading as a dead slab when the
|
||||
form has no cover image, and doubles as a scrim when it has one. */
|
||||
.wf.layout-split .wf-cover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(150deg, color-mix(in srgb, var(--wf-accent) 30%, transparent), transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.wf.layout-split .wf-cover > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
/* The panel fills its column and the padding centers the content inside it,
|
||||
so the surface is edge to edge without wrapping the content in a box. */
|
||||
.wf.layout-split .wf-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: var(--wf-form-bg, #ffffff);
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
padding: 64px max(48px, calc((100% - var(--wf-max-width, 560px)) / 2));
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.wf.layout-split .wf-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.wf.layout-split .wf-cover {
|
||||
min-height: 132px;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.wf.layout-split .wf-main {
|
||||
padding: 36px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- header bar ---- */
|
||||
.wf .wf-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: var(--wf-header-bg, #ffffff);
|
||||
border-bottom: 1px solid var(--wf-input-border, #e2e8f0);
|
||||
}
|
||||
/* Sticky keeps the brand in view down a long form. Page placement only: an
|
||||
inline header scrolls with the card it belongs to. */
|
||||
.wf .wf-header.is-sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
/* Inline sits with the form instead of spanning the viewport, so it drops the
|
||||
bar background and separates with air and a soft rule rather than a hard
|
||||
edge, which would read as a second card border. */
|
||||
.wf .wf-header.wf-header--inline {
|
||||
padding: 0 0 14px;
|
||||
background: transparent;
|
||||
border-bottom-color: color-mix(in srgb, var(--wf-input-border, #e2e8f0) 70%, transparent);
|
||||
margin-bottom: calc(var(--wf-gap, 16px) * 1.3);
|
||||
}
|
||||
.wf .wf-header.h-left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
/* Centered stacks the lockup: a logo beside a name reads as a bar, above it
|
||||
reads as a brand mark, which is what centering is asking for. */
|
||||
.wf .wf-header.h-center {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.wf .wf-header.h-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.wf .wf-header .wf-logo {
|
||||
margin: 0;
|
||||
height: auto;
|
||||
/* Honour the chosen logo size. The bar is chrome so it stays a little
|
||||
tighter than the form surface, but Large is still visibly large. */
|
||||
max-height: var(--wf-logo-h, 36px);
|
||||
max-width: 220px;
|
||||
}
|
||||
.wf .wf-header.wf-header--inline .wf-logo {
|
||||
max-width: 100%;
|
||||
}
|
||||
/* A site name has to be allowed to wrap: truncating it with an ellipsis in a
|
||||
560px card is how a header stops fitting. */
|
||||
.wf .wf-header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--wf-text);
|
||||
letter-spacing: -0.01em;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* ---- cover copy ---- */
|
||||
.wf .wf-cover-title {
|
||||
margin: 16px 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 1px 12px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
.wf .wf-cover-sub {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
text-shadow: 0 1px 10px rgba(15, 23, 42, 0.3);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* ---- branding ---- */
|
||||
.wf .wf-logo {
|
||||
display: block;
|
||||
height: var(--wf-logo-h, 36px);
|
||||
max-width: 60%;
|
||||
object-fit: contain;
|
||||
object-position: left center;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.wf.align-center .wf-main .wf-logo {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
object-position: center;
|
||||
}
|
||||
/* On the form surface the logo is brand chrome, not a field, so it gets more
|
||||
air beneath it than fields give each other. */
|
||||
.wf .card > .wf-logo,
|
||||
.wf .wf-bare > .wf-logo {
|
||||
margin-bottom: calc(var(--wf-gap, 16px) * 1.6);
|
||||
}
|
||||
/* Above the card it sits on the page background instead, so it needs the same
|
||||
spacing on the outside. */
|
||||
.wf .wf-main > .wf-logo {
|
||||
margin-bottom: calc(var(--wf-gap, 16px) * 1.4);
|
||||
}
|
||||
.wf .wf-header .wf-logo {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
/* ---- form container ---- */
|
||||
.wf .card {
|
||||
background: var(--wf-form-bg, #ffffff);
|
||||
border-radius: var(--wf-radius, 10px);
|
||||
padding: 32px;
|
||||
box-shadow: var(--wf-shadow, none);
|
||||
}
|
||||
.wf .wf-bare {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* ---- fields ---- */
|
||||
.wf .grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--wf-gap, 16px);
|
||||
}
|
||||
.wf .fld {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.wf .fld.half {
|
||||
flex: 1 1 calc(50% - var(--wf-gap, 16px));
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.wf .fld.half {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
.wf label.l {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--wf-label);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.wf .req {
|
||||
color: #dc2626;
|
||||
}
|
||||
.wf .help {
|
||||
font-size: 12px;
|
||||
color: var(--wf-placeholder);
|
||||
margin: 5px 0 0;
|
||||
}
|
||||
.wf .fielderr {
|
||||
font-size: 12px;
|
||||
color: #dc2626;
|
||||
margin: 5px 0 0;
|
||||
}
|
||||
.wf input[type="text"],
|
||||
.wf input[type="email"],
|
||||
.wf input[type="tel"],
|
||||
.wf input[type="date"],
|
||||
.wf input[type="number"],
|
||||
.wf select,
|
||||
.wf textarea {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--wf-input-text);
|
||||
background: var(--wf-input-bg);
|
||||
border: 1px solid var(--wf-input-border);
|
||||
border-radius: var(--wf-input-radius);
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.12s,
|
||||
box-shadow 0.12s;
|
||||
}
|
||||
.wf textarea {
|
||||
resize: vertical;
|
||||
min-height: 40px;
|
||||
}
|
||||
.wf input:focus,
|
||||
.wf select:focus,
|
||||
.wf textarea:focus {
|
||||
border-color: var(--wf-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wf-accent) 18%, transparent);
|
||||
}
|
||||
.wf ::placeholder {
|
||||
color: var(--wf-placeholder);
|
||||
}
|
||||
.wf .opts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.wf .opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--wf-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wf .opt input {
|
||||
accent-color: var(--wf-accent);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
.wf h2.h {
|
||||
margin: 2px 0 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--wf-text);
|
||||
}
|
||||
.wf p.p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--wf-text);
|
||||
opacity: 0.85;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.wf hr.d {
|
||||
border: none;
|
||||
border-top: 1px solid var(--wf-input-border);
|
||||
margin: 6px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- alignment ---- */
|
||||
.wf.align-center h2.h,
|
||||
.wf.align-center p.p,
|
||||
.wf.align-center .ok {
|
||||
text-align: center;
|
||||
}
|
||||
.wf.align-center .btnrow,
|
||||
.wf.align-center .wf-pagenav {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ---- buttons ---- */
|
||||
.wf .btnrow {
|
||||
display: flex;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.wf button.submit {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: var(--wf-btn-font, 14px);
|
||||
color: var(--wf-btn-text);
|
||||
background: var(--wf-btn-bg);
|
||||
border: none;
|
||||
border-radius: var(--wf-radius, 10px);
|
||||
padding: var(--wf-btn-pad, 10px 20px);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
filter 0.15s,
|
||||
transform 0.12s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.wf button.submit.full {
|
||||
width: 100%;
|
||||
}
|
||||
.wf button.submit:hover {
|
||||
filter: brightness(1.06);
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--wf-btn-bg) 35%, transparent);
|
||||
}
|
||||
.wf button.submit:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.wf button.submit:focus-visible {
|
||||
outline: 2px solid var(--wf-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.wf button.submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
box-shadow: none;
|
||||
}
|
||||
.wf button.wf-back {
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
font-size: var(--wf-btn-font, 14px);
|
||||
color: var(--wf-label);
|
||||
background: transparent;
|
||||
border: 1px solid var(--wf-input-border);
|
||||
border-radius: var(--wf-radius, 10px);
|
||||
padding: var(--wf-btn-pad, 10px 20px);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.wf button.wf-back:hover {
|
||||
border-color: var(--wf-label);
|
||||
}
|
||||
|
||||
/* ---- pages ---- */
|
||||
.wf .wf-progress {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--wf-accent) 15%, transparent);
|
||||
overflow: hidden;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.wf .wf-progress span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--wf-accent);
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
.wf .wf-pagenav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.wf .wf-pagetitle {
|
||||
margin: 0 0 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--wf-accent);
|
||||
width: 100%;
|
||||
}
|
||||
@keyframes wf-in-fwd {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@keyframes wf-in-back {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.wf .wf-step {
|
||||
width: 100%;
|
||||
}
|
||||
.wf .wf-step[data-dir="fwd"] {
|
||||
animation: wf-in-fwd 0.22s ease;
|
||||
}
|
||||
.wf .wf-step[data-dir="back"] {
|
||||
animation: wf-in-back 0.22s ease;
|
||||
}
|
||||
|
||||
/* ---- focus mode ---- */
|
||||
.wf.mode-focus .wf-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.wf.mode-focus label.l {
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.wf.mode-focus h2.h {
|
||||
font-size: 24px;
|
||||
}
|
||||
.wf.mode-focus .wf-hint {
|
||||
font-size: 12px;
|
||||
color: var(--wf-placeholder);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ---- messages ---- */
|
||||
.wf .err {
|
||||
font-size: 13px;
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
margin-bottom: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.wf .ok {
|
||||
text-align: center;
|
||||
padding: 36px 8px;
|
||||
}
|
||||
.wf .ok svg {
|
||||
color: #16a34a;
|
||||
}
|
||||
.wf .ok p {
|
||||
font-size: 15px;
|
||||
color: var(--wf-text);
|
||||
white-space: pre-line;
|
||||
}
|
||||
.wf .brand {
|
||||
text-align: center;
|
||||
margin-top: 14px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.wf .brand a {
|
||||
font-size: 11px;
|
||||
color: var(--wf-placeholder, #94a3b8);
|
||||
text-decoration: none;
|
||||
}
|
||||
.wf .cf-turnstile {
|
||||
margin: 2px 0;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Entry point: TanStack Router (code-based routes; the app has exactly one
|
||||
// real route) + TanStack Query, mounted under the shell the Go forms service
|
||||
// serves at /f/<publicId>.
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRootRoute, createRoute, createRouter, Outlet, RouterProvider } from "@tanstack/react-router";
|
||||
|
||||
import { FormPage } from "./FormPage";
|
||||
import { NotFound } from "./NotFound";
|
||||
import "./styles.css";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => <Outlet />,
|
||||
notFoundComponent: NotFound,
|
||||
});
|
||||
|
||||
const formRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/f/$publicId",
|
||||
component: FormPage,
|
||||
});
|
||||
|
||||
const router = createRouter({ routeTree: rootRoute.addChildren([formRoute]) });
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
/* Hosted-page chrome only; the themed form itself is styled by the mirrored
|
||||
form-theme.css (all .wf-scoped, driven by the --wf-* variables). */
|
||||
|
||||
@import "./form-theme.css";
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Embedded: the host page owns the backdrop, and viewport-height tricks
|
||||
would feed back into the iframe auto-resize loop. */
|
||||
body.embed .wf,
|
||||
body.embed .wf.layout-split .wf-cover,
|
||||
body.embed .wf.mode-focus .wf-main {
|
||||
min-height: auto;
|
||||
background: transparent;
|
||||
}
|
||||
body.embed .wf .wf-main {
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.hpwrap {
|
||||
position: absolute;
|
||||
left: -5000px;
|
||||
top: -5000px;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plain {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 26rem;
|
||||
margin: 5rem auto;
|
||||
padding: 0 1rem;
|
||||
color: #0f172a;
|
||||
text-align: center;
|
||||
}
|
||||
.plain h1 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.plain p {
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
}
|
||||
.plain button {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.4rem;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
}
|
||||
.plain button:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Cloudflare Turnstile script plumbing, split from the widget component so
|
||||
// the component file only exports components (react-refresh constraint).
|
||||
|
||||
export interface TurnstileAPI {
|
||||
render: (el: HTMLElement, opts: Record<string, unknown>) => string;
|
||||
remove: (id: string) => void;
|
||||
reset: (id?: string) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileAPI;
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||
|
||||
let scriptLoading: Promise<void> | null = null;
|
||||
|
||||
export function loadTurnstileScript(): Promise<void> {
|
||||
if (window.turnstile) return Promise.resolve();
|
||||
scriptLoading ??= new Promise((resolve, reject) => {
|
||||
const s = document.createElement("script");
|
||||
s.src = SCRIPT_SRC;
|
||||
s.async = true;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error("turnstile script failed to load"));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return scriptLoading;
|
||||
}
|
||||
|
||||
export function resetTurnstile() {
|
||||
try {
|
||||
window.turnstile?.reset();
|
||||
} catch {
|
||||
// a widget that was never rendered has nothing to reset
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// The built app is served by the Go forms service (cmd/forms), which owns
|
||||
// /f/<publicId> shells, per-form CSP headers and the same-origin /api. The
|
||||
// dev server proxies /api to a locally running `make forms` service.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5175,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.FORMS_API_URL ?? "http://localhost:8090",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/api/middleware"
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// formScope pulls the org and the :id path param once per handler.
|
||||
func formScope(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
raw := c.Param("id")
|
||||
if raw == "" {
|
||||
return *orgID, uuid.Nil, true
|
||||
}
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "invalid form id"))
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
return *orgID, id, true
|
||||
}
|
||||
|
||||
// withFormShareURL stamps the hosted page URL onto the response, on the
|
||||
// organization's verified custom forms domain when it has one.
|
||||
func (h *Handler) withFormShareURL(c *gin.Context, orgID uuid.UUID, f *models.Form) *models.Form {
|
||||
f.ShareURL = config.FormURLOn(h.FormService.FormsHost(c.Request.Context(), orgID), f.PublicID)
|
||||
return f
|
||||
}
|
||||
|
||||
func (h *Handler) ListForms(c *gin.Context) {
|
||||
orgID, _, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, xerr := h.FormService.List(c.Request.Context(), orgID)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
host := h.FormService.FormsHost(c.Request.Context(), orgID)
|
||||
for i := range out {
|
||||
out[i].ShareURL = config.FormURLOn(host, out[i].PublicID)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
// GetFormsConfig tells the builder what this instance can do, so the UI
|
||||
// never shows a captcha toggle that cannot work.
|
||||
func (h *Handler) GetFormsConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"base_url": config.FormsBaseURL(),
|
||||
"captcha_available": config.CaptchaProvider() != "none" && config.TurnstileSiteKey() != "",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GetForm(c *gin.Context) {
|
||||
orgID, id, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, xerr := h.FormService.Get(c.Request.Context(), orgID, id)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, h.withFormShareURL(c, orgID, out))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateForm(c *gin.Context) {
|
||||
orgID, _, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
errx.Handle(c, errx.ErrInvalid)
|
||||
return
|
||||
}
|
||||
var createdBy *uuid.UUID
|
||||
if uid, err := middleware.GetUserUUID(c); err == nil {
|
||||
createdBy = &uid
|
||||
}
|
||||
out, xerr := h.FormService.Create(c.Request.Context(), orgID, createdBy, in.Name)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
h.auditOrg(c, models.AuditActionCreate, models.AuditEntityForm, &out.ID, nil, map[string]string{"name": out.Name})
|
||||
c.JSON(http.StatusCreated, h.withFormShareURL(c, orgID, out))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateForm(c *gin.Context) {
|
||||
orgID, id, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in models.FormWrite
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
errx.Handle(c, errx.ErrInvalid)
|
||||
return
|
||||
}
|
||||
out, xerr := h.FormService.Update(c.Request.Context(), orgID, id, &in)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityForm, &out.ID, nil, map[string]string{"name": out.Name, "status": string(out.Status)})
|
||||
c.JSON(http.StatusOK, h.withFormShareURL(c, orgID, out))
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteForm(c *gin.Context) {
|
||||
orgID, id, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if xerr := h.FormService.Delete(c.Request.Context(), orgID, id); xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityForm, &id, nil, nil)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetFormStats serves the analytics tab: funnel totals, daily series,
|
||||
// per-page drop-off, breakdowns and identified visitors.
|
||||
func (h *Handler) GetFormStats(c *gin.Context) {
|
||||
orgID, id, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
days := 30
|
||||
switch c.Query("range") {
|
||||
case "", "30d":
|
||||
case "7d":
|
||||
days = 7
|
||||
case "90d":
|
||||
days = 90
|
||||
default:
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "range must be 7d, 30d or 90d"))
|
||||
return
|
||||
}
|
||||
out, xerr := h.FormService.Stats(c.Request.Context(), orgID, id, days)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetCampaignForms reports how the forms linked from a campaign performed for
|
||||
// that campaign's recipients. It hangs off the campaign group, so the id
|
||||
// param is a campaign, not a form.
|
||||
func (h *Handler) GetCampaignForms(c *gin.Context) {
|
||||
orgID := middleware.GetOrganizationID(c)
|
||||
if orgID == nil {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
||||
return
|
||||
}
|
||||
campaignID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "invalid campaign id"))
|
||||
return
|
||||
}
|
||||
out, xerr := h.FormService.CampaignForms(c.Request.Context(), *orgID, campaignID)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
// GetFormsDomain, SetFormsDomain and VerifyFormsDomain manage the
|
||||
// organization's custom forms domain. Reading is a stored-state lookup; the
|
||||
// other two resolve DNS, which is why they are not on the read path.
|
||||
func (h *Handler) GetFormsDomain(c *gin.Context) {
|
||||
orgID, _, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, xerr := h.FormService.FormsDomainStatus(c.Request.Context(), orgID)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *Handler) SetFormsDomain(c *gin.Context) {
|
||||
orgID, _, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
FormsDomain string `json:"forms_domain"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
errx.Handle(c, errx.ErrInvalid)
|
||||
return
|
||||
}
|
||||
out, xerr := h.FormService.SetFormsDomain(c.Request.Context(), orgID, in.FormsDomain)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntitySettings, nil, nil,
|
||||
map[string]string{"forms_domain": out.FormsDomain, "verified": strconv.FormatBool(out.FormsDomainVerified)})
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyFormsDomain(c *gin.Context) {
|
||||
orgID, _, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, xerr := h.FormService.VerifyFormsDomain(c.Request.Context(), orgID)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// MintFormLink returns the personalized URL for one contact. GET on top of
|
||||
// an upsert: retries always land on the same token, so it is naturally safe.
|
||||
func (h *Handler) MintFormLink(c *gin.Context) {
|
||||
orgID, id, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
contactID, err := uuid.Parse(c.Param("contactID"))
|
||||
if err != nil {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "invalid contact id"))
|
||||
return
|
||||
}
|
||||
url, xerr := h.FormService.MintLink(c.Request.Context(), orgID, id, contactID)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// Form image caps: logos stay small, covers may be a full panel.
|
||||
const (
|
||||
formLogoMaxBytes int64 = 1 * 1024 * 1024
|
||||
formLogoMaxDim = 1024
|
||||
formCoverMaxBytes int64 = 4 * 1024 * 1024
|
||||
formCoverMaxDim = 2560
|
||||
)
|
||||
|
||||
// readFormImageUpload mirrors readAvatarUpload with per-kind caps and copy;
|
||||
// the MIME allowlist (PNG/JPG only) is shared for the same CVE reasons.
|
||||
func readFormImageUpload(c *gin.Context, kind string) (body []byte, mime, ext string, xerr *errx.Error) {
|
||||
maxBytes, maxDim := formLogoMaxBytes, formLogoMaxDim
|
||||
// Covers and page backgrounds are both full-bleed art, so they share caps.
|
||||
if kind == "cover" || kind == "background" {
|
||||
maxBytes, maxDim = formCoverMaxBytes, formCoverMaxDim
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+1024)
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return nil, "", "", errx.New(errx.BadRequest, "file is required")
|
||||
}
|
||||
if fh.Size > maxBytes {
|
||||
return nil, "", "", errx.New(errx.BadRequest, fmt.Sprintf("the %s must be smaller than %d MB", kind, maxBytes>>20))
|
||||
}
|
||||
src, err := fh.Open()
|
||||
if err != nil {
|
||||
return nil, "", "", errx.InternalError()
|
||||
}
|
||||
defer src.Close()
|
||||
buf := &bytes.Buffer{}
|
||||
if _, err := io.Copy(buf, src); err != nil {
|
||||
return nil, "", "", errx.InternalError()
|
||||
}
|
||||
body = buf.Bytes()
|
||||
mime = http.DetectContentType(body)
|
||||
var ok bool
|
||||
if ext, ok = allowedAvatarMIME[mime]; !ok {
|
||||
return nil, "", "", errx.New(errx.BadRequest, fmt.Sprintf("the %s must be a PNG or JPG", kind))
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, "", "", errx.New(errx.BadRequest, "image could not be parsed")
|
||||
}
|
||||
if cfg.Width > maxDim || cfg.Height > maxDim {
|
||||
return nil, "", "", errx.New(errx.BadRequest, fmt.Sprintf("the %s must be %dpx or smaller", kind, maxDim))
|
||||
}
|
||||
return body, mime, ext, nil
|
||||
}
|
||||
|
||||
func formAssetKind(c *gin.Context) (string, bool) {
|
||||
kind := c.Param("kind")
|
||||
if kind != "logo" && kind != "cover" && kind != "background" {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "kind must be logo, cover or background"))
|
||||
return "", false
|
||||
}
|
||||
return kind, true
|
||||
}
|
||||
|
||||
// UploadFormAsset — POST /forms/:id/assets/:kind (multipart, field "file").
|
||||
func (h *Handler) UploadFormAsset(c *gin.Context) {
|
||||
orgID, id, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
kind, ok := formAssetKind(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, mime, ext, xerr := readFormImageUpload(c, kind)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("form-assets/%s/%s-%s-%d%s", orgID, id, kind, time.Now().Unix(), ext)
|
||||
url, xerr := putPublicObject(c.Request.Context(), h.Storage, key, body, mime)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
out, xerr := h.FormService.SetAsset(c.Request.Context(), orgID, id, kind, url)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityForm, &id, nil, map[string]string{"asset": kind})
|
||||
c.JSON(http.StatusOK, h.withFormShareURL(c, orgID, out))
|
||||
}
|
||||
|
||||
// DeleteFormAsset — DELETE /forms/:id/assets/:kind.
|
||||
func (h *Handler) DeleteFormAsset(c *gin.Context) {
|
||||
orgID, id, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
kind, ok := formAssetKind(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, xerr := h.FormService.SetAsset(c.Request.Context(), orgID, id, kind, "")
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityForm, &id, nil, map[string]string{"asset": kind, "removed": "true"})
|
||||
c.JSON(http.StatusOK, h.withFormShareURL(c, orgID, out))
|
||||
}
|
||||
|
||||
func (h *Handler) ListFormSubmissions(c *gin.Context) {
|
||||
orgID, id, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if raw := c.Query("limit"); raw != "" {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 100 {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "limit must be between 1 and 100"))
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
var before *time.Time
|
||||
if raw := c.Query("before"); raw != "" {
|
||||
t, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if err != nil {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "before must be an RFC 3339 timestamp"))
|
||||
return
|
||||
}
|
||||
before = &t
|
||||
}
|
||||
out, hasMore, xerr := h.FormService.ListSubmissions(c.Request.Context(), orgID, id, limit, before)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out, "has_more": hasMore})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteFormSubmission(c *gin.Context) {
|
||||
orgID, id, ok := formScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subID, err := uuid.Parse(c.Param("sid"))
|
||||
if err != nil {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "invalid submission id"))
|
||||
return
|
||||
}
|
||||
if xerr := h.FormService.DeleteSubmission(c.Request.Context(), orgID, id, subID); xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityForm, &id, nil, map[string]string{"submission_id": subID.String()})
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/app/emailsend"
|
||||
emailverifyapp "github.com/warmbly/warmbly/internal/app/emailverify"
|
||||
"github.com/warmbly/warmbly/internal/app/feature"
|
||||
"github.com/warmbly/warmbly/internal/app/form"
|
||||
"github.com/warmbly/warmbly/internal/app/group"
|
||||
"github.com/warmbly/warmbly/internal/app/instancecheck"
|
||||
"github.com/warmbly/warmbly/internal/app/instanceconfig"
|
||||
@@ -101,6 +102,7 @@ type Handler struct {
|
||||
CampaignService campaign.CampaignService
|
||||
ContactService contact.ContactService
|
||||
SegmentService segment.Service
|
||||
FormService form.Service
|
||||
SequenceService sequence.SequenceService
|
||||
UniboxService unibox.UniboxService
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/warmbly/warmbly/internal/app/form"
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/formwire"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// The forms service's slice of the internal API (INTERNAL_API_TOKEN, same
|
||||
// pattern as the worker DEK proxy and the tracking link resolver): fetch a
|
||||
// published form, record a funnel event, forward a submission. The service
|
||||
// owns the public HTML and the per-visitor abuse checks; the pipeline that
|
||||
// turns answers into contacts stays here.
|
||||
|
||||
func formCaptchaSiteKey(f *models.Form) string {
|
||||
if !f.CaptchaEnabled || config.CaptchaProvider() == "none" {
|
||||
return ""
|
||||
}
|
||||
return config.TurnstileSiteKey()
|
||||
}
|
||||
|
||||
// InternalGetPublicForm resolves a published form for the forms service.
|
||||
// 404 for unknown or unpublished ids, so the service can negative-cache. A
|
||||
// valid ?t= ticket adds the contact's prefill values and echoes the token.
|
||||
func (h *Handler) InternalGetPublicForm(c *gin.Context) {
|
||||
f, xerr := h.FormService.PublicForm(c.Request.Context(), c.Param("publicID"))
|
||||
if xerr != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
out := formwire.PublicForm{
|
||||
PublicID: f.PublicID,
|
||||
Name: f.Name,
|
||||
Fields: f.Fields,
|
||||
Design: f.Design,
|
||||
LogoURL: f.LogoURL,
|
||||
CoverURL: f.CoverURL,
|
||||
BackgroundURL: f.BackgroundURL,
|
||||
AllowedDomains: f.AllowedDomains,
|
||||
CaptchaSiteKey: formCaptchaSiteKey(f),
|
||||
}
|
||||
if token := c.Query("t"); token != "" {
|
||||
if link, prefill := h.FormService.ResolveLink(c.Request.Context(), f, token); link != nil {
|
||||
out.Prefill = prefill
|
||||
out.LinkToken = token
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// InternalRecordFormEvent stores one funnel event; the forms service already
|
||||
// deduped views, filtered prefetches and budgeted the source.
|
||||
func (h *Handler) InternalRecordFormEvent(c *gin.Context) {
|
||||
var req formwire.EventRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body"})
|
||||
return
|
||||
}
|
||||
xerr := h.FormService.RecordEvent(c.Request.Context(), c.Param("publicID"), form.EventInput{
|
||||
Type: req.Type,
|
||||
PageIndex: req.PageIndex,
|
||||
PagesTotal: req.PagesTotal,
|
||||
VisitorKey: req.VisitorKey,
|
||||
SourceURL: req.SourceURL,
|
||||
LinkToken: req.LinkToken,
|
||||
RemoteIP: req.RemoteIP,
|
||||
UserAgent: req.UserAgent,
|
||||
})
|
||||
if xerr != nil {
|
||||
if xerr.Code == errx.NotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_event"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// InternalSubmitForm runs the submission pipeline for answers the forms
|
||||
// service collected. The 400 body's message is visitor-facing: the service
|
||||
// shows it inline on the form.
|
||||
func (h *Handler) InternalSubmitForm(c *gin.Context) {
|
||||
var req formwire.SubmitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, formwire.SubmitError{Error: "invalid_body", Message: "The submission could not be read."})
|
||||
return
|
||||
}
|
||||
meta := form.SubmitMeta{
|
||||
RemoteIP: req.RemoteIP,
|
||||
SourceURL: req.SourceURL,
|
||||
CaptchaToken: req.CaptchaToken,
|
||||
HoneypotFilled: req.HoneypotFilled,
|
||||
LinkToken: req.LinkToken,
|
||||
VisitorKey: req.VisitorKey,
|
||||
}
|
||||
if req.RenderedAt > 0 {
|
||||
meta.RenderedAt = time.Unix(req.RenderedAt, 0)
|
||||
}
|
||||
res, xerr := h.FormService.Submit(c.Request.Context(), c.Param("publicID"), req.Answers, meta)
|
||||
if xerr != nil {
|
||||
if xerr.Code == errx.NotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
// Message only: the errx prefix ("Bad Request (400):") is for logs,
|
||||
// not for a visitor's inline error.
|
||||
c.JSON(http.StatusBadRequest, formwire.SubmitError{Error: "form_submit_failed", Message: xerr.Message})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, formwire.SubmitResult{Message: res.Message, RedirectURL: res.RedirectURL})
|
||||
}
|
||||
@@ -56,5 +56,6 @@ func isPublicKey(key string) bool {
|
||||
if strings.Contains(key, "..") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(key, "avatars/") || strings.HasPrefix(key, "oauth-app-logos/")
|
||||
return strings.HasPrefix(key, "avatars/") || strings.HasPrefix(key, "oauth-app-logos/") ||
|
||||
strings.HasPrefix(key, "form-assets/")
|
||||
}
|
||||
|
||||
@@ -144,6 +144,13 @@ func Run(
|
||||
// instead of carrying it all in the install-time env file.
|
||||
internal.GET("/worker/config", h.InternalWorkerConfig)
|
||||
internal.POST("/worker/heartbeat", h.InternalWorkerHeartbeat)
|
||||
|
||||
// Hosted forms: the forms service (cmd/forms) resolves published
|
||||
// forms, forwards deduped funnel events and visitor submissions
|
||||
// here; the answers-to-contact pipeline runs on this side.
|
||||
internal.GET("/forms/:publicID", h.InternalGetPublicForm)
|
||||
internal.POST("/forms/:publicID/events", h.InternalRecordFormEvent)
|
||||
internal.POST("/forms/:publicID/submissions", h.InternalSubmitForm)
|
||||
}
|
||||
|
||||
corsConfig := cors.Config{
|
||||
@@ -482,6 +489,9 @@ func Run(
|
||||
campaigns.POST("/:id/stop", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.StopCampaign)
|
||||
campaigns.GET("/:id/logs", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaignLogs)
|
||||
|
||||
// Form performance for this campaign's recipients.
|
||||
campaigns.GET("/:id/forms", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaignForms)
|
||||
|
||||
// Explicit sender pool (rotation/weighting).
|
||||
campaigns.GET("/:id/senders", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListCampaignSenders)
|
||||
campaigns.PUT("/:id/senders", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.ReplaceCampaignSenders)
|
||||
@@ -640,6 +650,32 @@ func Run(
|
||||
}
|
||||
|
||||
// folders, email-account tags, and contact categories.
|
||||
// Forms: hosted lead capture. Contact permissions on both sides,
|
||||
// since a form exists only to create and update contacts.
|
||||
forms := protected.Group("/forms")
|
||||
forms.Use(m.RateLimitMiddleware(models.RateLimitWrite))
|
||||
{
|
||||
forms.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListForms)
|
||||
forms.GET("/config", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetFormsConfig)
|
||||
// The custom forms domain is workspace-wide branding, so it
|
||||
// sits behind the settings gate rather than the contacts one.
|
||||
// Static siblings, declared before the /:id routes.
|
||||
forms.GET("/domain", m.RequireOrganization(), m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetFormsDomain)
|
||||
forms.PUT("/domain", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteContacts), h.SetFormsDomain)
|
||||
forms.POST("/domain/verify", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteContacts), h.VerifyFormsDomain)
|
||||
forms.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.CreateForm)
|
||||
forms.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetForm)
|
||||
forms.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.UpdateForm)
|
||||
forms.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.DeleteForm)
|
||||
forms.GET("/:id/submissions", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListFormSubmissions)
|
||||
forms.DELETE("/:id/submissions/:sid", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.DeleteFormSubmission)
|
||||
forms.GET("/:id/stats", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetFormStats)
|
||||
// Minting writes a link row, hence the manage gate on a GET.
|
||||
forms.GET("/:id/links/:contactID", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.MintFormLink)
|
||||
forms.POST("/:id/assets/:kind", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.UploadFormAsset)
|
||||
forms.DELETE("/:id/assets/:kind", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.DeleteFormAsset)
|
||||
}
|
||||
|
||||
grouph.New(protected, h.FolderService, h.AuditService, "folders", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns))
|
||||
grouph.New(protected, h.TagService, h.AuditService, "tags", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails))
|
||||
grouph.New(protected, h.CategoryService, h.AuditService, "categories", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts))
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package form
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/trackdns"
|
||||
)
|
||||
|
||||
// OrgStore is the slice of the organization repository the form service
|
||||
// needs: the custom forms domain, and the workspace owner used as the
|
||||
// fallback actor when a form outlives the member who created it. Kept narrow
|
||||
// so the service does not depend on the whole organization repository.
|
||||
type OrgStore interface {
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*models.Organization, error)
|
||||
GetFormsDomain(ctx context.Context, orgID uuid.UUID) (string, bool, error)
|
||||
SetFormsDomain(ctx context.Context, orgID uuid.UUID, domain string) error
|
||||
SetFormsDomainVerified(ctx context.Context, orgID uuid.UUID, verified bool, at *time.Time) error
|
||||
}
|
||||
|
||||
// FormsHost returns the host every form URL for this organization is built
|
||||
// on: their own domain once it verifies, otherwise this install's shared
|
||||
// forms host.
|
||||
//
|
||||
// Only a verified domain is honored, for the same reason the tracking host
|
||||
// works that way: a name that does not resolve turns every form link already
|
||||
// sitting in a recipient's inbox into a dead link, which is worse than a link
|
||||
// on the shared host.
|
||||
func (s *service) FormsHost(ctx context.Context, orgID uuid.UUID) string {
|
||||
shared := config.FormsHostname()
|
||||
if s.domains == nil {
|
||||
return shared
|
||||
}
|
||||
domain, verified, err := s.domains.GetFormsDomain(ctx, orgID)
|
||||
if err != nil || !verified {
|
||||
return shared
|
||||
}
|
||||
if host := config.NormalizeTrackingHost(domain); host != "" {
|
||||
return host
|
||||
}
|
||||
return shared
|
||||
}
|
||||
|
||||
// FormsDomainStatus reports the stored state without doing DNS work, for the
|
||||
// settings screen's first paint.
|
||||
func (s *service) FormsDomainStatus(ctx context.Context, orgID uuid.UUID) (*models.FormsDomainStatus, *errx.Error) {
|
||||
if s.domains == nil {
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
domain, verified, err := s.domains.GetFormsDomain(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out := &models.FormsDomainStatus{
|
||||
FormsDomain: domain,
|
||||
FormsDomainVerified: verified,
|
||||
CNAMETarget: config.FormsHostname(),
|
||||
}
|
||||
switch {
|
||||
case domain == "":
|
||||
out.Status = trackdns.CodeUnset
|
||||
out.Message = "No custom forms domain is set; form links use the shared host."
|
||||
case out.CNAMETarget == "":
|
||||
out.Status = trackdns.CodeNoTarget
|
||||
out.Message = "This install has no forms host configured, so there is nothing to point a record at."
|
||||
case verified:
|
||||
out.Status = trackdns.CodeVerified
|
||||
out.Message = "Verified. Form links are built on this domain."
|
||||
default:
|
||||
out.Status = "pending"
|
||||
out.Message = "Not verified yet; form links use the shared host until it resolves."
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetFormsDomain stores a new custom forms domain and immediately resolves
|
||||
// it, so saving and verifying are one action for the customer. An empty value
|
||||
// clears it back to the shared host.
|
||||
func (s *service) SetFormsDomain(ctx context.Context, orgID uuid.UUID, raw string) (*models.FormsDomainStatus, *errx.Error) {
|
||||
if s.domains == nil {
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
domain := config.NormalizeTrackingHost(raw)
|
||||
if domain != "" && !validFormsDomain(domain) {
|
||||
return nil, errx.New(errx.BadRequest, "that does not look like a domain name")
|
||||
}
|
||||
if err := s.domains.SetFormsDomain(ctx, orgID, domain); err != nil {
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if domain == "" {
|
||||
return s.FormsDomainStatus(ctx, orgID)
|
||||
}
|
||||
return s.VerifyFormsDomain(ctx, orgID)
|
||||
}
|
||||
|
||||
// VerifyFormsDomain re-resolves the stored domain and records the verdict.
|
||||
// An unresolved record is not an error: it stays unverified with a reason the
|
||||
// customer can act on, and form links keep working on the shared host.
|
||||
func (s *service) VerifyFormsDomain(ctx context.Context, orgID uuid.UUID) (*models.FormsDomainStatus, *errx.Error) {
|
||||
if s.domains == nil {
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
domain, _, err := s.domains.GetFormsDomain(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
|
||||
target := config.FormsHostname()
|
||||
res := trackdns.Verify(ctx, domain, target)
|
||||
|
||||
out := &models.FormsDomainStatus{
|
||||
FormsDomain: res.Domain,
|
||||
FormsDomainVerified: res.Verified,
|
||||
CNAMETarget: target,
|
||||
Status: res.Code,
|
||||
Message: res.Reason,
|
||||
Observed: res.Observed,
|
||||
FormsHostUnresolvable: res.TargetUnresolvable,
|
||||
}
|
||||
if res.Verified {
|
||||
now := time.Now().UTC()
|
||||
out.FormsDomainVerifiedAt = &now
|
||||
}
|
||||
if err := s.domains.SetFormsDomainVerified(ctx, orgID, res.Verified, out.FormsDomainVerifiedAt); err != nil {
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validFormsDomain rejects the obvious non-domains before a DNS lookup: a
|
||||
// bare label, an IP-looking value, or anything with characters a hostname
|
||||
// cannot carry.
|
||||
func validFormsDomain(host string) bool {
|
||||
if len(host) > 253 || !strings.Contains(host, ".") {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(host, ".") {
|
||||
if label == "" || len(label) > 63 {
|
||||
return false
|
||||
}
|
||||
for i, r := range label {
|
||||
isAlnum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||
if isAlnum {
|
||||
continue
|
||||
}
|
||||
// A hyphen is legal inside a label, never at either end.
|
||||
if r == '-' && i > 0 && i < len(label)-1 {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package form
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidFormsDomain(t *testing.T) {
|
||||
valid := []string{
|
||||
"forms.acme.com",
|
||||
"go.acme.co.uk",
|
||||
"a-b.example.com",
|
||||
"x1.y2.example.io",
|
||||
}
|
||||
for _, v := range valid {
|
||||
if !validFormsDomain(v) {
|
||||
t.Errorf("validFormsDomain(%q) = false, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"", // empty
|
||||
"localhost", // no dot
|
||||
"-lead.acme.com", // label starts with a hyphen
|
||||
"lead-.acme.com", // label ends with a hyphen
|
||||
"acme..com", // empty label
|
||||
"acme.com/forms", // path is not part of a host
|
||||
"forms acme.com", // space
|
||||
"forms.acme.com:", // stray separator
|
||||
}
|
||||
for _, v := range invalid {
|
||||
if validFormsDomain(v) {
|
||||
t.Errorf("validFormsDomain(%q) = true, want false", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package form
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/mileusna/useragent"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// EventInput is one funnel event as the forms service forwards it. RemoteIP
|
||||
// feeds the country lookup and is discarded; it is never stored.
|
||||
type EventInput struct {
|
||||
Type string
|
||||
PageIndex int
|
||||
PagesTotal int
|
||||
VisitorKey string
|
||||
SourceURL string
|
||||
LinkToken string
|
||||
RemoteIP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
func (s *service) RecordEvent(ctx context.Context, publicID string, in EventInput) *errx.Error {
|
||||
f, xerr := s.PublicForm(ctx, publicID)
|
||||
if xerr != nil {
|
||||
return xerr
|
||||
}
|
||||
if s.events == nil {
|
||||
return nil
|
||||
}
|
||||
t := models.FormEventType(in.Type)
|
||||
if !t.Valid() || t == models.FormEventSubmit {
|
||||
// Submits are recorded by the submit pipeline, never by the client.
|
||||
return errx.New(errx.BadRequest, "unknown event type")
|
||||
}
|
||||
|
||||
ev := &models.FormEvent{
|
||||
OrganizationID: f.OrganizationID,
|
||||
FormID: f.ID,
|
||||
Type: t,
|
||||
VisitorKey: truncate(in.VisitorKey, 64),
|
||||
PageIndex: clampInt(in.PageIndex, 0, 200),
|
||||
PagesTotal: clampInt(in.PagesTotal, 0, 200),
|
||||
ReferrerDomain: referrerDomain(in.SourceURL),
|
||||
CountryCode: s.countryFor(in.RemoteIP),
|
||||
Device: deviceFor(in.UserAgent),
|
||||
}
|
||||
if link, _ := s.ResolveLink(ctx, f, in.LinkToken); link != nil {
|
||||
id := link.ContactID
|
||||
ev.ContactID = &id
|
||||
ev.CampaignID = link.CampaignID
|
||||
}
|
||||
if xerr := s.events.Insert(ctx, ev); xerr != nil {
|
||||
return xerr
|
||||
}
|
||||
if t == models.FormEventView {
|
||||
s.RecordView(ctx, f.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// countryFor resolves the country only; private and loopback sources are
|
||||
// skipped, and the IP is dropped right after the lookup.
|
||||
func (s *service) countryFor(ip string) string {
|
||||
if s.geo == nil || ip == "" {
|
||||
return ""
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(ip))
|
||||
if err != nil || addr.IsPrivate() || addr.IsLoopback() {
|
||||
return ""
|
||||
}
|
||||
info, err := s.geo.Lookup(addr)
|
||||
if err != nil || info == nil {
|
||||
return ""
|
||||
}
|
||||
return info.CountryCode
|
||||
}
|
||||
|
||||
func deviceFor(ua string) string {
|
||||
if ua == "" {
|
||||
return "unknown"
|
||||
}
|
||||
parsed := useragent.Parse(ua)
|
||||
switch {
|
||||
case parsed.Tablet:
|
||||
return "tablet"
|
||||
case parsed.Mobile:
|
||||
return "mobile"
|
||||
case parsed.Desktop:
|
||||
return "desktop"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func referrerDomain(sourceURL string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(sourceURL))
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
return truncate(strings.TrimPrefix(host, "www."), 253)
|
||||
}
|
||||
|
||||
// formPageCount is how many pages the field list renders as.
|
||||
func formPageCount(fields []models.FormField) int {
|
||||
pages := 1
|
||||
for _, f := range fields {
|
||||
if f.Type == models.FormFieldPageBreak {
|
||||
pages++
|
||||
}
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
func clampInt(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package form
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
func (s *service) MintLink(ctx context.Context, orgID, formID, contactID uuid.UUID) (string, *errx.Error) {
|
||||
if s.links == nil {
|
||||
return "", errx.InternalError()
|
||||
}
|
||||
f, xerr := s.repo.Get(ctx, orgID, formID)
|
||||
if xerr != nil {
|
||||
return "", xerr
|
||||
}
|
||||
token, xerr := s.links.Upsert(ctx, orgID, formID, contactID, nil)
|
||||
if xerr != nil {
|
||||
return "", xerr
|
||||
}
|
||||
base := config.FormURLOn(s.FormsHost(ctx, orgID), f.PublicID)
|
||||
if base == "" {
|
||||
return "", errx.New(errx.BadRequest, "FORMS_DOMAIN is not configured")
|
||||
}
|
||||
return base + "?t=" + token.String(), nil
|
||||
}
|
||||
|
||||
func (s *service) MintForContact(ctx context.Context, orgID uuid.UUID, formPublicID string, contactID uuid.UUID, campaignID *uuid.UUID) (string, string, bool) {
|
||||
f, xerr := s.repo.GetByPublicID(ctx, formPublicID)
|
||||
if xerr != nil || f.OrganizationID != orgID || f.Status != models.FormStatusPublished {
|
||||
return "", "", false
|
||||
}
|
||||
// The org's verified custom domain, so the URL that lands in an email
|
||||
// carries the customer's own name rather than the shared forms host.
|
||||
shareURL := config.FormURLOn(s.FormsHost(ctx, orgID), f.PublicID)
|
||||
if shareURL == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if s.links == nil {
|
||||
return shareURL, shareURL, true
|
||||
}
|
||||
token, terr := s.links.Upsert(ctx, orgID, f.ID, contactID, campaignID)
|
||||
if terr != nil {
|
||||
// A failed mint degrades to the shared URL rather than failing a send.
|
||||
return shareURL, shareURL, true
|
||||
}
|
||||
return shareURL + "?t=" + token.String(), shareURL, true
|
||||
}
|
||||
|
||||
func (s *service) ResolveLink(ctx context.Context, f *models.Form, token string) (*models.FormLink, map[string]string) {
|
||||
if s.links == nil || token == "" || f == nil {
|
||||
return nil, nil
|
||||
}
|
||||
id, err := uuid.Parse(token)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
link, xerr := s.links.Resolve(ctx, id)
|
||||
if xerr != nil || link.FormID != f.ID {
|
||||
return nil, nil
|
||||
}
|
||||
return link, s.prefillFor(ctx, f, link.ContactID)
|
||||
}
|
||||
|
||||
// prefillFor maps the contact's columns onto the form's mapped fields.
|
||||
func (s *service) prefillFor(ctx context.Context, f *models.Form, contactID uuid.UUID) map[string]string {
|
||||
if s.contactReader == nil {
|
||||
return nil
|
||||
}
|
||||
c, xerr := s.contactReader.GetByID(ctx, contactID)
|
||||
if xerr != nil {
|
||||
return nil
|
||||
}
|
||||
values := map[string]string{
|
||||
"first_name": c.FirstName,
|
||||
"last_name": c.LastName,
|
||||
"email": c.Email,
|
||||
"company": c.Company,
|
||||
"phone": c.Phone,
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, field := range f.Fields {
|
||||
if field.MapTo == "" {
|
||||
continue
|
||||
}
|
||||
if v := values[field.MapTo]; v != "" {
|
||||
out[field.ID] = v
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
// Package form owns hosted lead-capture forms: the builder CRUD the
|
||||
// dashboard edits, and the public render/submit pipeline that turns a
|
||||
// visitor's answers into a contact, category links and a campaign lead.
|
||||
package form
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/geo"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
"github.com/warmbly/warmbly/internal/utils"
|
||||
"github.com/warmbly/warmbly/internal/utils/validate"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
List(ctx context.Context, orgID uuid.UUID) ([]models.Form, *errx.Error)
|
||||
Get(ctx context.Context, orgID, id uuid.UUID) (*models.Form, *errx.Error)
|
||||
Create(ctx context.Context, orgID uuid.UUID, createdBy *uuid.UUID, name string) (*models.Form, *errx.Error)
|
||||
Update(ctx context.Context, orgID, id uuid.UUID, in *models.FormWrite) (*models.Form, *errx.Error)
|
||||
Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error
|
||||
ListSubmissions(ctx context.Context, orgID, formID uuid.UUID, limit int, before *time.Time) ([]models.FormSubmission, bool, *errx.Error)
|
||||
DeleteSubmission(ctx context.Context, orgID, formID, subID uuid.UUID) *errx.Error
|
||||
// SetAsset stores an uploaded brand asset URL; an empty url clears it.
|
||||
SetAsset(ctx context.Context, orgID, id uuid.UUID, kind, url string) (*models.Form, *errx.Error)
|
||||
// Stats aggregates the funnel events for the analytics tab.
|
||||
Stats(ctx context.Context, orgID, formID uuid.UUID, rangeDays int) (*models.FormStats, *errx.Error)
|
||||
// CampaignForms reports how every form this campaign links to performed
|
||||
// for that campaign's recipients.
|
||||
CampaignForms(ctx context.Context, orgID, campaignID uuid.UUID) ([]models.CampaignFormStats, *errx.Error)
|
||||
|
||||
// FormsHost is the host this organization's form URLs are built on: their
|
||||
// verified custom domain, or the install's shared forms host.
|
||||
FormsHost(ctx context.Context, orgID uuid.UUID) string
|
||||
FormsDomainStatus(ctx context.Context, orgID uuid.UUID) (*models.FormsDomainStatus, *errx.Error)
|
||||
SetFormsDomain(ctx context.Context, orgID uuid.UUID, raw string) (*models.FormsDomainStatus, *errx.Error)
|
||||
VerifyFormsDomain(ctx context.Context, orgID uuid.UUID) (*models.FormsDomainStatus, *errx.Error)
|
||||
|
||||
// MintLink returns the personalized form URL for a contact (dashboard
|
||||
// on-demand path). MintForContact backs the campaign-send resolver: it is
|
||||
// public-id scoped to the org and reports ok=false for an unknown or
|
||||
// unpublished form so the caller can fall back to the share URL.
|
||||
MintLink(ctx context.Context, orgID, formID, contactID uuid.UUID) (string, *errx.Error)
|
||||
MintForContact(ctx context.Context, orgID uuid.UUID, formPublicID string, contactID uuid.UUID, campaignID *uuid.UUID) (personalURL, shareURL string, ok bool)
|
||||
|
||||
// PublicForm resolves a published form for the hosted page.
|
||||
PublicForm(ctx context.Context, publicID string) (*models.Form, *errx.Error)
|
||||
// RecordView bumps the view counter, deduped per source by the caller.
|
||||
RecordView(ctx context.Context, formID uuid.UUID)
|
||||
// RecordEvent stores one funnel event, enriched server-side.
|
||||
RecordEvent(ctx context.Context, publicID string, in EventInput) *errx.Error
|
||||
// ResolveLink turns a ?t= token into the link it names (contact and the
|
||||
// campaign whose email carried it) plus the prefill values for the form;
|
||||
// anything invalid resolves to nil, never an error, so a bad token
|
||||
// degrades to an anonymous visit.
|
||||
ResolveLink(ctx context.Context, f *models.Form, token string) (*models.FormLink, map[string]string)
|
||||
// Submit runs the public pipeline: validate, contact upsert, store.
|
||||
Submit(ctx context.Context, publicID string, answers map[string][]string, meta SubmitMeta) (*SubmitResult, *errx.Error)
|
||||
|
||||
// SetCaptcha wires the Turnstile verifier; nil leaves captcha off.
|
||||
SetCaptcha(v CaptchaVerifier)
|
||||
SetRealtime(p RealtimePublisher)
|
||||
SetWebhooks(d WebhookDispatcher)
|
||||
SetContacts(a ContactAdder)
|
||||
SetContactReader(r ContactReader)
|
||||
SetGeo(g *geo.Client)
|
||||
SetLinks(r repository.FormLinkRepository)
|
||||
SetEvents(r repository.FormEventRepository)
|
||||
SetDomains(d OrgStore)
|
||||
}
|
||||
|
||||
// ContactReader is the slice of the contact repository the link paths need.
|
||||
type ContactReader interface {
|
||||
GetByID(ctx context.Context, contactID uuid.UUID) (*models.Contact, *errx.Error)
|
||||
}
|
||||
|
||||
// ContactAdder is the slice of the contact service the submit pipeline needs.
|
||||
type ContactAdder interface {
|
||||
Add(ctx context.Context, userID string, orgID uuid.UUID, contacts []models.AddContact) ([]models.Contact, *errx.Error)
|
||||
}
|
||||
|
||||
// CaptchaVerifier validates a Turnstile token for a source IP.
|
||||
type CaptchaVerifier interface {
|
||||
Verify(ctx context.Context, token, remoteIP string) *errx.Error
|
||||
}
|
||||
|
||||
// RealtimePublisher fans the submission signal out to the org channel.
|
||||
type RealtimePublisher interface {
|
||||
PublishFormSubmission(ctx context.Context, orgID, formID uuid.UUID, submissionID, contactID string)
|
||||
}
|
||||
|
||||
// WebhookDispatcher delivers form.submitted to subscribed endpoints.
|
||||
type WebhookDispatcher interface {
|
||||
Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error)
|
||||
}
|
||||
|
||||
// SubmitMeta is everything the handler knows about the request that is not
|
||||
// an answer: abuse signals and attribution.
|
||||
type SubmitMeta struct {
|
||||
RemoteIP string
|
||||
SourceURL string
|
||||
CaptchaToken string
|
||||
// HoneypotFilled means the hidden trap field carried a value.
|
||||
HoneypotFilled bool
|
||||
// RenderedAt is when the page was served; bots submit near-instantly.
|
||||
RenderedAt time.Time
|
||||
// LinkToken is the personalized ?t= ticket; its contact wins attribution.
|
||||
LinkToken string
|
||||
// VisitorKey ties the submission to the visitor's funnel events.
|
||||
VisitorKey string
|
||||
}
|
||||
|
||||
// SubmitResult is what the visitor sees after a successful submit.
|
||||
type SubmitResult struct {
|
||||
Message string `json:"message"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo repository.FormRepository
|
||||
links repository.FormLinkRepository
|
||||
events repository.FormEventRepository
|
||||
domains OrgStore
|
||||
contacts ContactAdder
|
||||
contactReader ContactReader
|
||||
captcha CaptchaVerifier
|
||||
realtime RealtimePublisher
|
||||
webhooks WebhookDispatcher
|
||||
geo *geo.Client
|
||||
}
|
||||
|
||||
func NewService(repo repository.FormRepository) Service {
|
||||
return &service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *service) SetCaptcha(v CaptchaVerifier) { s.captcha = v }
|
||||
func (s *service) SetRealtime(p RealtimePublisher) { s.realtime = p }
|
||||
func (s *service) SetWebhooks(d WebhookDispatcher) { s.webhooks = d }
|
||||
func (s *service) SetContacts(a ContactAdder) { s.contacts = a }
|
||||
func (s *service) SetContactReader(r ContactReader) { s.contactReader = r }
|
||||
func (s *service) SetGeo(g *geo.Client) { s.geo = g }
|
||||
func (s *service) SetLinks(r repository.FormLinkRepository) { s.links = r }
|
||||
func (s *service) SetEvents(r repository.FormEventRepository) { s.events = r }
|
||||
func (s *service) SetDomains(d OrgStore) { s.domains = d }
|
||||
|
||||
// formTrendDays is the sparkline window on the forms list.
|
||||
const formTrendDays = 14
|
||||
|
||||
func (s *service) List(ctx context.Context, orgID uuid.UUID) ([]models.Form, *errx.Error) {
|
||||
forms, xerr := s.repo.List(ctx, orgID)
|
||||
if xerr != nil || s.events == nil {
|
||||
return forms, xerr
|
||||
}
|
||||
// Attach the event rollups; losing them must never fail the list.
|
||||
aggs, aerr := s.events.ListAggregates(ctx, orgID, formTrendDays)
|
||||
if aerr != nil {
|
||||
return forms, nil
|
||||
}
|
||||
for i := range forms {
|
||||
if a, ok := aggs[forms[i].ID]; ok {
|
||||
forms[i].StartsCount = a.Starts
|
||||
forms[i].IdentifiedCount = a.Identified
|
||||
forms[i].Trend = a.Trend
|
||||
} else {
|
||||
forms[i].Trend = make([]int64, formTrendDays)
|
||||
}
|
||||
}
|
||||
return forms, nil
|
||||
}
|
||||
|
||||
func (s *service) Get(ctx context.Context, orgID, id uuid.UUID) (*models.Form, *errx.Error) {
|
||||
return s.repo.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (s *service) Create(ctx context.Context, orgID uuid.UUID, createdBy *uuid.UUID, name string) (*models.Form, *errx.Error) {
|
||||
name, xerr := models.ValidateFormName(name)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
f := &models.Form{
|
||||
PublicID: newPublicID(),
|
||||
Name: name,
|
||||
Status: models.FormStatusDraft,
|
||||
Fields: defaultFields(),
|
||||
SuccessMessage: models.FormDefaultSuccessMsg,
|
||||
}
|
||||
return s.repo.Create(ctx, orgID, createdBy, f)
|
||||
}
|
||||
|
||||
// defaultFields seeds a new form so the builder never opens empty.
|
||||
func defaultFields() []models.FormField {
|
||||
return []models.FormField{
|
||||
{ID: "first_name", Type: models.FormFieldText, Label: "First name", MapTo: "first_name", Width: "half"},
|
||||
{ID: "last_name", Type: models.FormFieldText, Label: "Last name", MapTo: "last_name", Width: "half"},
|
||||
{ID: "email", Type: models.FormFieldEmail, Label: "Email", MapTo: "email", Required: true},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Update(ctx context.Context, orgID, id uuid.UUID, in *models.FormWrite) (*models.Form, *errx.Error) {
|
||||
f, xerr := s.repo.Get(ctx, orgID, id)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if in.Name != nil {
|
||||
name, xerr := models.ValidateFormName(*in.Name)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
f.Name = name
|
||||
}
|
||||
if in.Fields != nil {
|
||||
fields := *in.Fields
|
||||
if xerr := models.ValidateFormFields(fields); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
f.Fields = fields
|
||||
}
|
||||
if in.Design != nil {
|
||||
d := *in.Design
|
||||
if xerr := models.ValidateFormDesign(&d); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
models.NormalizeFormDesign(&d)
|
||||
f.Design = d
|
||||
}
|
||||
if in.SuccessMessage != nil {
|
||||
msg := strings.TrimSpace(*in.SuccessMessage)
|
||||
if len(msg) > models.FormMaxTextLen {
|
||||
return nil, errx.New(errx.BadRequest, "success message is too long")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = models.FormDefaultSuccessMsg
|
||||
}
|
||||
f.SuccessMessage = msg
|
||||
}
|
||||
if in.RedirectURL != nil {
|
||||
u, xerr := models.ValidateFormRedirectURL(*in.RedirectURL)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
f.RedirectURL = u
|
||||
}
|
||||
if in.CampaignID.Set {
|
||||
f.CampaignID = in.CampaignID.Value
|
||||
}
|
||||
if in.CategoryIDs != nil {
|
||||
f.CategoryIDs = *in.CategoryIDs
|
||||
}
|
||||
if in.AllowedDomains != nil {
|
||||
domains, xerr := models.ValidateFormDomains(*in.AllowedDomains)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
f.AllowedDomains = domains
|
||||
}
|
||||
if in.CaptchaEnabled != nil {
|
||||
f.CaptchaEnabled = *in.CaptchaEnabled
|
||||
}
|
||||
if in.Status != nil {
|
||||
if !in.Status.Valid() {
|
||||
return nil, errx.New(errx.BadRequest, "status must be draft, published or archived")
|
||||
}
|
||||
if *in.Status == models.FormStatusPublished && f.Status != models.FormStatusPublished {
|
||||
if xerr := publishable(f.Fields); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
now := time.Now()
|
||||
f.PublishedAt = &now
|
||||
}
|
||||
f.Status = *in.Status
|
||||
}
|
||||
return s.repo.Update(ctx, orgID, f)
|
||||
}
|
||||
|
||||
// publishable refuses to put a form online that cannot collect anything.
|
||||
func publishable(fields []models.FormField) *errx.Error {
|
||||
for _, f := range fields {
|
||||
if f.Type.IsInput() && f.Type != models.FormFieldHidden {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errx.New(errx.BadRequest, "add at least one input field before publishing")
|
||||
}
|
||||
|
||||
func (s *service) SetAsset(ctx context.Context, orgID, id uuid.UUID, kind, url string) (*models.Form, *errx.Error) {
|
||||
var logo, cover, background *string
|
||||
switch kind {
|
||||
case "logo":
|
||||
logo = &url
|
||||
case "cover":
|
||||
cover = &url
|
||||
case "background":
|
||||
background = &url
|
||||
default:
|
||||
return nil, errx.New(errx.BadRequest, "kind must be logo, cover or background")
|
||||
}
|
||||
return s.repo.UpdateAssets(ctx, orgID, id, logo, cover, background)
|
||||
}
|
||||
|
||||
func (s *service) Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error {
|
||||
return s.repo.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (s *service) ListSubmissions(ctx context.Context, orgID, formID uuid.UUID, limit int, before *time.Time) ([]models.FormSubmission, bool, *errx.Error) {
|
||||
return s.repo.ListSubmissions(ctx, orgID, formID, limit, before)
|
||||
}
|
||||
|
||||
func (s *service) DeleteSubmission(ctx context.Context, orgID, formID, subID uuid.UUID) *errx.Error {
|
||||
return s.repo.DeleteSubmission(ctx, orgID, formID, subID)
|
||||
}
|
||||
|
||||
func (s *service) PublicForm(ctx context.Context, publicID string) (*models.Form, *errx.Error) {
|
||||
f, xerr := s.repo.GetByPublicID(ctx, publicID)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if f.Status != models.FormStatusPublished {
|
||||
return nil, errx.New(errx.NotFound, "form not found")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *service) RecordView(ctx context.Context, formID uuid.UUID) {
|
||||
// Best-effort: a lost view must never fail a page render.
|
||||
_ = s.repo.RecordView(ctx, formID)
|
||||
}
|
||||
|
||||
// minFillSeconds is the human floor: a submit faster than this after render
|
||||
// is treated as a bot and quietly discarded.
|
||||
const minFillSeconds = 2
|
||||
|
||||
func (s *service) Submit(ctx context.Context, publicID string, answers map[string][]string, meta SubmitMeta) (*SubmitResult, *errx.Error) {
|
||||
f, xerr := s.PublicForm(ctx, publicID)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
ok := &SubmitResult{Message: f.SuccessMessage, RedirectURL: f.RedirectURL}
|
||||
|
||||
// Traps: pretend success so a bot learns nothing and stops probing.
|
||||
if meta.HoneypotFilled {
|
||||
return ok, nil
|
||||
}
|
||||
if !meta.RenderedAt.IsZero() && time.Since(meta.RenderedAt) < minFillSeconds*time.Second {
|
||||
return ok, nil
|
||||
}
|
||||
if f.CaptchaEnabled && s.captcha != nil {
|
||||
if xerr := s.captcha.Verify(ctx, meta.CaptchaToken, meta.RemoteIP); xerr != nil {
|
||||
return nil, errx.New(errx.BadRequest, "captcha verification failed")
|
||||
}
|
||||
}
|
||||
|
||||
data, lead, xerr := buildSubmission(f.Fields, answers)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
|
||||
sub := &models.FormSubmission{
|
||||
FormID: f.ID,
|
||||
OrganizationID: f.OrganizationID,
|
||||
Data: data,
|
||||
SourceURL: truncate(meta.SourceURL, 2048),
|
||||
}
|
||||
|
||||
link, _ := s.ResolveLink(ctx, f, meta.LinkToken)
|
||||
if link != nil {
|
||||
sub.CampaignID = link.CampaignID
|
||||
}
|
||||
|
||||
// The contact write is best-effort: a plan cap or a bad address must not
|
||||
// lose the submission, and never the visitor's success page.
|
||||
var contactID string
|
||||
if lead != nil && s.contacts != nil {
|
||||
// A form outlives the member who made it: created_by is ON DELETE SET
|
||||
// NULL, so offboarding that person would otherwise stop lead capture
|
||||
// silently while submissions kept storing. Contacts are org-owned, so
|
||||
// the workspace owner is the right stand-in actor.
|
||||
owner := f.CreatedBy
|
||||
if owner == nil && s.domains != nil {
|
||||
if org, oerr := s.domains.GetByID(ctx, f.OrganizationID); oerr == nil && org != nil {
|
||||
owner = &org.OwnerUserID
|
||||
}
|
||||
}
|
||||
if owner != nil {
|
||||
lead.Campaigns = campaignList(f.CampaignID)
|
||||
lead.Categories = categoryList(f.CategoryIDs)
|
||||
lead.Source = models.ContactSourceForm
|
||||
lead.SourceDetail = f.Name
|
||||
created, cerr := s.contacts.Add(ctx, owner.String(), f.OrganizationID, []models.AddContact{*lead})
|
||||
if cerr == nil && len(created) > 0 {
|
||||
id := created[0].ID
|
||||
sub.ContactID = &id
|
||||
contactID = id.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A personalized link identifies the visitor outright, so its contact
|
||||
// wins over whatever the email upsert matched.
|
||||
if link != nil {
|
||||
id := link.ContactID
|
||||
sub.ContactID = &id
|
||||
contactID = id.String()
|
||||
}
|
||||
|
||||
stored, xerr := s.repo.CreateSubmission(ctx, sub)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if s.events != nil {
|
||||
_ = s.events.Insert(ctx, &models.FormEvent{
|
||||
OrganizationID: f.OrganizationID,
|
||||
FormID: f.ID,
|
||||
Type: models.FormEventSubmit,
|
||||
VisitorKey: truncate(meta.VisitorKey, 64),
|
||||
PagesTotal: formPageCount(f.Fields),
|
||||
ContactID: sub.ContactID,
|
||||
CampaignID: sub.CampaignID,
|
||||
ReferrerDomain: referrerDomain(meta.SourceURL),
|
||||
Device: "unknown",
|
||||
})
|
||||
}
|
||||
if sub.ContactID != nil {
|
||||
_ = s.repo.LogSubmissionActivity(ctx, f.OrganizationID, *sub.ContactID, f.ID, f.Name, stored.ID)
|
||||
}
|
||||
if s.realtime != nil {
|
||||
s.realtime.PublishFormSubmission(ctx, f.OrganizationID, f.ID, stored.ID.String(), contactID)
|
||||
}
|
||||
if s.webhooks != nil {
|
||||
payload := map[string]any{
|
||||
"form_id": f.ID.String(),
|
||||
"form_name": f.Name,
|
||||
"submission_id": stored.ID.String(),
|
||||
"data": data,
|
||||
"source_url": sub.SourceURL,
|
||||
}
|
||||
if contactID != "" {
|
||||
payload["contact_id"] = contactID
|
||||
}
|
||||
if sub.CampaignID != nil {
|
||||
payload["campaign_id"] = sub.CampaignID.String()
|
||||
}
|
||||
_, _ = s.webhooks.Dispatch(ctx, f.OrganizationID, models.WebhookEventFormSubmitted, payload)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// buildSubmission validates the answers against the field list and splits
|
||||
// them into the stored payload and the contact to upsert. A nil lead means
|
||||
// the form collected no usable email.
|
||||
func buildSubmission(fields []models.FormField, answers map[string][]string) (map[string]any, *models.AddContact, *errx.Error) {
|
||||
data := make(map[string]any, len(fields))
|
||||
cols := map[string]string{}
|
||||
custom := map[string]string{}
|
||||
|
||||
for _, f := range fields {
|
||||
if !f.Type.IsInput() {
|
||||
continue
|
||||
}
|
||||
vals := answers[f.ID]
|
||||
label := f.Label
|
||||
if label == "" {
|
||||
label = f.ID
|
||||
}
|
||||
|
||||
if f.Type == models.FormFieldCheckboxes {
|
||||
picked := make([]string, 0, len(vals))
|
||||
allowed := optionSet(f.Options)
|
||||
for _, v := range vals {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if !allowed[v] {
|
||||
return nil, nil, errx.New(errx.BadRequest, label+": unknown option")
|
||||
}
|
||||
picked = append(picked, v)
|
||||
}
|
||||
if f.Required && len(picked) == 0 {
|
||||
return nil, nil, errx.New(errx.BadRequest, label+" is required")
|
||||
}
|
||||
if len(picked) > 0 {
|
||||
data[f.ID] = picked
|
||||
custom[customKey(f)] = strings.Join(picked, ", ")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
v := ""
|
||||
if len(vals) > 0 {
|
||||
v = strings.TrimSpace(vals[0])
|
||||
}
|
||||
if f.Type == models.FormFieldHidden && v == "" {
|
||||
v = f.Value
|
||||
}
|
||||
if len(v) > models.FormMaxAnswerLen {
|
||||
return nil, nil, errx.New(errx.BadRequest, label+" is too long")
|
||||
}
|
||||
|
||||
switch f.Type {
|
||||
case models.FormFieldCheckbox:
|
||||
checked := v == "on" || v == "true" || v == "1" || v == "yes"
|
||||
if f.Required && !checked {
|
||||
return nil, nil, errx.New(errx.BadRequest, label+" must be checked")
|
||||
}
|
||||
if checked {
|
||||
data[f.ID] = "yes"
|
||||
custom[customKey(f)] = "yes"
|
||||
}
|
||||
continue
|
||||
case models.FormFieldEmail:
|
||||
if v != "" && !validate.Email(v) {
|
||||
return nil, nil, errx.New(errx.BadRequest, label+" must be a valid email address")
|
||||
}
|
||||
v = strings.ToLower(v)
|
||||
case models.FormFieldNumber:
|
||||
if v != "" {
|
||||
if _, err := strconv.ParseFloat(v, 64); err != nil {
|
||||
return nil, nil, errx.New(errx.BadRequest, label+" must be a number")
|
||||
}
|
||||
}
|
||||
case models.FormFieldDate:
|
||||
if v != "" {
|
||||
if _, err := time.Parse("2006-01-02", v); err != nil {
|
||||
return nil, nil, errx.New(errx.BadRequest, label+" must be a date")
|
||||
}
|
||||
}
|
||||
case models.FormFieldSelect, models.FormFieldRadio:
|
||||
if v != "" && !optionSet(f.Options)[v] {
|
||||
return nil, nil, errx.New(errx.BadRequest, label+": unknown option")
|
||||
}
|
||||
}
|
||||
|
||||
if f.Required && v == "" {
|
||||
return nil, nil, errx.New(errx.BadRequest, label+" is required")
|
||||
}
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
data[f.ID] = v
|
||||
if f.MapTo != "" {
|
||||
cols[f.MapTo] = v
|
||||
} else {
|
||||
custom[customKey(f)] = v
|
||||
}
|
||||
}
|
||||
|
||||
if cols["email"] == "" {
|
||||
return data, nil, nil
|
||||
}
|
||||
lead := &models.AddContact{
|
||||
Email: cols["email"],
|
||||
FirstName: cols["first_name"],
|
||||
LastName: cols["last_name"],
|
||||
Company: cols["company"],
|
||||
Phone: cols["phone"],
|
||||
CustomFields: custom,
|
||||
}
|
||||
return data, lead, nil
|
||||
}
|
||||
|
||||
var jsonKeyStrip = regexp.MustCompile(`[^A-Za-z0-9_ -]+`)
|
||||
|
||||
// customKey turns the field label into an addressable contact custom-field
|
||||
// key ("Company size" stays as-is; punctuation is stripped so "Role?" still
|
||||
// yields a key the template engine can resolve). The field id is the
|
||||
// fallback when nothing usable remains.
|
||||
func customKey(f models.FormField) string {
|
||||
label := f.Label
|
||||
if label == "" {
|
||||
label = f.ID
|
||||
}
|
||||
key := utils.NormalizeJSONKey(label)
|
||||
if !utils.IsValidJSONKey(key) {
|
||||
key = utils.NormalizeJSONKey(jsonKeyStrip.ReplaceAllString(key, " "))
|
||||
}
|
||||
if utils.IsValidJSONKey(key) {
|
||||
return key
|
||||
}
|
||||
return f.ID
|
||||
}
|
||||
|
||||
func optionSet(opts []string) map[string]bool {
|
||||
set := make(map[string]bool, len(opts))
|
||||
for _, o := range opts {
|
||||
set[o] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func campaignList(id *uuid.UUID) []string {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
return []string{id.String()}
|
||||
}
|
||||
|
||||
func categoryList(ids []uuid.UUID) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, id.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// publicIDAlphabet keeps the token URL-safe and unambiguous.
|
||||
const publicIDAlphabet = "abcdefghijkmnpqrstuvwxyz23456789"
|
||||
|
||||
// newPublicID mints the unguessable page token: 21 chars of 32-symbol
|
||||
// alphabet is over 100 bits of entropy.
|
||||
func newPublicID() string {
|
||||
buf := make([]byte, 21)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return uuid.NewString()
|
||||
}
|
||||
for i, b := range buf {
|
||||
buf[i] = publicIDAlphabet[int(b)%len(publicIDAlphabet)]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package form
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
func demoFields() []models.FormField {
|
||||
return []models.FormField{
|
||||
{ID: "first", Type: models.FormFieldText, Label: "First name", MapTo: "first_name"},
|
||||
{ID: "email", Type: models.FormFieldEmail, Label: "Email", Required: true, MapTo: "email"},
|
||||
{ID: "size", Type: models.FormFieldSelect, Label: "Company size", Options: []string{"1-10", "11-50"}},
|
||||
{ID: "topics", Type: models.FormFieldCheckboxes, Label: "Topics", Options: []string{"Warmup", "Campaigns"}},
|
||||
{ID: "agree", Type: models.FormFieldCheckbox, Label: "Consent", Required: true},
|
||||
{ID: "utm", Type: models.FormFieldHidden, Label: "Source", Value: "landing"},
|
||||
{ID: "h", Type: models.FormFieldHeading, Label: "Heading"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubmissionHappyPath(t *testing.T) {
|
||||
data, lead, xerr := buildSubmission(demoFields(), map[string][]string{
|
||||
"first": {" Ada "},
|
||||
"email": {"Ada@Example.COM"},
|
||||
"size": {"1-10"},
|
||||
"topics": {"Warmup", "Campaigns"},
|
||||
"agree": {"on"},
|
||||
})
|
||||
if xerr != nil {
|
||||
t.Fatalf("unexpected error: %v", xerr)
|
||||
}
|
||||
if lead == nil {
|
||||
t.Fatal("expected a lead")
|
||||
}
|
||||
if lead.Email != "ada@example.com" || lead.FirstName != "Ada" {
|
||||
t.Fatalf("bad mapping: %+v", lead)
|
||||
}
|
||||
if data["utm"] != "landing" {
|
||||
t.Fatalf("hidden constant missing: %v", data["utm"])
|
||||
}
|
||||
if got := lead.CustomFields["Company size"]; got != "1-10" {
|
||||
t.Fatalf("custom field company_size = %q", got)
|
||||
}
|
||||
if got, ok := data["topics"].([]string); !ok || len(got) != 2 {
|
||||
t.Fatalf("topics = %#v", data["topics"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubmissionValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
answers map[string][]string
|
||||
}{
|
||||
{"missing required email", map[string][]string{"agree": {"on"}}},
|
||||
{"bad email", map[string][]string{"email": {"not-an-email"}, "agree": {"on"}}},
|
||||
{"unknown option", map[string][]string{"email": {"a@b.co"}, "size": {"evil"}, "agree": {"on"}}},
|
||||
{"unchecked required consent", map[string][]string{"email": {"a@b.co"}}},
|
||||
{"unknown checkbox value", map[string][]string{"email": {"a@b.co"}, "agree": {"on"}, "topics": {"nope"}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if _, _, xerr := buildSubmission(demoFields(), tc.answers); xerr == nil {
|
||||
t.Fatalf("%s: expected an error", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubmissionWithoutEmailFieldStoresDataOnly(t *testing.T) {
|
||||
fields := []models.FormField{{ID: "msg", Type: models.FormFieldTextarea, Label: "Message"}}
|
||||
data, lead, xerr := buildSubmission(fields, map[string][]string{"msg": {"hi"}})
|
||||
if xerr != nil {
|
||||
t.Fatalf("unexpected error: %v", xerr)
|
||||
}
|
||||
if lead != nil {
|
||||
t.Fatal("no email answer must mean no contact")
|
||||
}
|
||||
if data["msg"] != "hi" {
|
||||
t.Fatalf("data = %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFormFieldsRejectsDuplicateMapTo(t *testing.T) {
|
||||
fields := []models.FormField{
|
||||
{ID: "a", Type: models.FormFieldText, Label: "A", MapTo: "first_name"},
|
||||
{ID: "b", Type: models.FormFieldText, Label: "B", MapTo: "first_name"},
|
||||
}
|
||||
if xerr := models.ValidateFormFields(fields); xerr == nil {
|
||||
t.Fatal("expected duplicate map_to to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicIDShape(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 50; i++ {
|
||||
id := newPublicID()
|
||||
if len(id) != 21 {
|
||||
t.Fatalf("bad length: %q", id)
|
||||
}
|
||||
if seen[id] {
|
||||
t.Fatal("duplicate public id")
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageBreakIsStructureOnly(t *testing.T) {
|
||||
fields := append(demoFields(), models.FormField{ID: "pb1", Type: models.FormFieldPageBreak, Label: "About you"})
|
||||
if xerr := models.ValidateFormFields(fields); xerr != nil {
|
||||
t.Fatalf("page_break rejected: %v", xerr)
|
||||
}
|
||||
// A titleless break is fine too.
|
||||
fields = append(fields, models.FormField{ID: "pb2", Type: models.FormFieldPageBreak})
|
||||
if xerr := models.ValidateFormFields(fields); xerr != nil {
|
||||
t.Fatalf("untitled page_break rejected: %v", xerr)
|
||||
}
|
||||
data, _, xerr := buildSubmission(fields, map[string][]string{
|
||||
"email": {"a@b.co"}, "agree": {"yes"},
|
||||
})
|
||||
if xerr != nil {
|
||||
t.Fatalf("submit with page breaks: %v", xerr)
|
||||
}
|
||||
if _, ok := data["pb1"]; ok {
|
||||
t.Fatal("page_break leaked into submission data")
|
||||
}
|
||||
// A form of only layout blocks stays unpublishable even with breaks.
|
||||
if xerr := publishable([]models.FormField{{ID: "pb", Type: models.FormFieldPageBreak}}); xerr == nil {
|
||||
t.Fatal("page-break-only form must not publish")
|
||||
}
|
||||
if got := formPageCount(fields); got != 3 {
|
||||
t.Fatalf("page count = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFormDesignV2(t *testing.T) {
|
||||
good := models.FormDesign{
|
||||
Layout: "split", Mode: "focus", Align: "center", Theme: "midnight",
|
||||
PageBackgroundEnd: "#0f172a", FontFamily: "fraunces",
|
||||
}
|
||||
if xerr := models.ValidateFormDesign(&good); xerr != nil {
|
||||
t.Fatalf("valid v2 design rejected: %v", xerr)
|
||||
}
|
||||
for _, bad := range []models.FormDesign{
|
||||
{Layout: "sideways"},
|
||||
{Mode: "carousel"},
|
||||
{Align: "justify"},
|
||||
{Theme: "Not A Slug"},
|
||||
{PageBackgroundEnd: "blue"},
|
||||
{FontFamily: "comic-sans"},
|
||||
} {
|
||||
b := bad
|
||||
if xerr := models.ValidateFormDesign(&b); xerr == nil {
|
||||
t.Fatalf("invalid design accepted: %+v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferrerDomain(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://www.example.com/pricing?x=1": "example.com",
|
||||
"https://sub.shop.io/": "sub.shop.io",
|
||||
"not a url": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := referrerDomain(in); got != want {
|
||||
t.Fatalf("referrerDomain(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package form
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
statsBucketLimit = 8
|
||||
statsIdentifiedLimit = 25
|
||||
)
|
||||
|
||||
func (s *service) Stats(ctx context.Context, orgID, formID uuid.UUID, rangeDays int) (*models.FormStats, *errx.Error) {
|
||||
switch rangeDays {
|
||||
case 7, 30, 90:
|
||||
default:
|
||||
return nil, errx.New(errx.BadRequest, "range must be 7d, 30d or 90d")
|
||||
}
|
||||
f, xerr := s.repo.Get(ctx, orgID, formID)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if s.events == nil {
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
from := time.Now().AddDate(0, 0, -rangeDays)
|
||||
|
||||
totals, xerr := s.events.Totals(ctx, orgID, formID, from)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
daily, xerr := s.events.DailySeries(ctx, orgID, formID, from)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
funnel, xerr := s.events.PageFunnel(ctx, orgID, formID, from, formPageCount(f.Fields))
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
stitchPageTitles(f.Fields, funnel)
|
||||
sources, xerr := s.events.Breakdown(ctx, orgID, formID, from, "referrer_domain", statsBucketLimit)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
countries, xerr := s.events.Breakdown(ctx, orgID, formID, from, "country_code", statsBucketLimit)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
devices, xerr := s.events.Breakdown(ctx, orgID, formID, from, "device", statsBucketLimit)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
campaigns, xerr := s.events.CampaignBreakdown(ctx, orgID, formID, from, statsBucketLimit)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
identified, xerr := s.events.RecentIdentified(ctx, orgID, formID, from, statsIdentifiedLimit)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
|
||||
return &models.FormStats{
|
||||
Totals: *totals,
|
||||
Daily: daily,
|
||||
Pages: funnel,
|
||||
Sources: sources,
|
||||
Countries: countries,
|
||||
Devices: devices,
|
||||
Campaigns: campaigns,
|
||||
Identified: identified,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// stitchPageTitles labels funnel rows from the form's page breaks: page 0 is
|
||||
// the opening page, every later page takes its break's label.
|
||||
func stitchPageTitles(fields []models.FormField, funnel []models.FormFunnelPage) {
|
||||
titles := []string{""}
|
||||
for _, f := range fields {
|
||||
if f.Type == models.FormFieldPageBreak {
|
||||
titles = append(titles, f.Label)
|
||||
}
|
||||
}
|
||||
for i := range funnel {
|
||||
idx := funnel[i].PageIndex
|
||||
title := ""
|
||||
if idx >= 0 && idx < len(titles) {
|
||||
title = titles[idx]
|
||||
}
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Page %d", idx+1)
|
||||
}
|
||||
funnel[i].Title = title
|
||||
}
|
||||
}
|
||||
|
||||
// CampaignForms answers the campaign side of the same funnel: per form, how
|
||||
// many recipients got a personalized link and how far they went.
|
||||
func (s *service) CampaignForms(ctx context.Context, orgID, campaignID uuid.UUID) ([]models.CampaignFormStats, *errx.Error) {
|
||||
if s.events == nil {
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
rows, xerr := s.events.CampaignFormPerformance(ctx, orgID, campaignID)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
host := s.FormsHost(ctx, orgID)
|
||||
for i := range rows {
|
||||
rows[i].ShareURL = config.FormURLOn(host, rows[i].PublicID)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -527,6 +527,12 @@ var table = []Entry{
|
||||
DocsAnchor: docsCaptcha,
|
||||
Resolve: envValue("TURNSTILE_BYPASS_TOKEN"),
|
||||
},
|
||||
{
|
||||
Key: "TURNSTILE_SITE_KEY", Group: GroupCaptcha, RuntimeChangeable: ChangePerRequest,
|
||||
Effect: "The public Turnstile widget key hosted form pages render with. Unset leaves spam-protected forms without a captcha.",
|
||||
DocsAnchor: docsCaptcha,
|
||||
Resolve: envValue("TURNSTILE_SITE_KEY"),
|
||||
},
|
||||
|
||||
// Encryption.
|
||||
{
|
||||
@@ -723,6 +729,18 @@ var table = []Entry{
|
||||
},
|
||||
|
||||
// Tracking.
|
||||
{
|
||||
Key: "FORMS_DOMAIN", Group: GroupAddresses, RuntimeChangeable: ChangePerRequest,
|
||||
Effect: "The host hosted form pages and embeds are served on (forms.example.com, routed to the forms service). Share links and embed codes point here; unset leaves forms without a public URL.",
|
||||
DocsAnchor: docsAddresses,
|
||||
Resolve: envValue("FORMS_DOMAIN"),
|
||||
},
|
||||
{
|
||||
Key: "FORM_IP_RATE_LIMIT", Group: GroupAddresses, RuntimeChangeable: ChangeBootOnly,
|
||||
Effect: "Public form submissions allowed per source IP per 10 minutes, enforced by the forms service. Default 30.",
|
||||
DocsAnchor: docsAddresses, WhenUnset: SourceDerived,
|
||||
Resolve: envValue("FORM_IP_RATE_LIMIT"),
|
||||
},
|
||||
{
|
||||
Key: "TRACKING_DOMAIN", Group: GroupTracking, RuntimeChangeable: ChangePerRequest,
|
||||
Effect: "The host open pixels and click links point at. Recipients still receive mail when it is wrong, but nothing is recorded.",
|
||||
|
||||
@@ -357,7 +357,7 @@ func (b *blobCollector) addURL(url, table, column string) {
|
||||
}
|
||||
|
||||
// publicKeyPrefixes are the object-key prefixes reachable through a public URL.
|
||||
var publicKeyPrefixes = []string{"avatars/"}
|
||||
var publicKeyPrefixes = []string{"avatars/", "form-assets/"}
|
||||
|
||||
// ---------- small JSON helpers ----------
|
||||
|
||||
|
||||
@@ -549,7 +549,30 @@ func (s *service) mergeOrganization(ctx context.Context, tx pgx.Tx, orgID uuid.U
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.MergeOrganization(ctx, tx, orgID, cols, raw)
|
||||
if err := s.repo.MergeOrganization(ctx, tx, orgID, cols, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The verdict columns are excluded above, which preserves whatever the
|
||||
// DESTINATION already held rather than resetting them. On a workspace that
|
||||
// had its own verified forms domain, importing a different one would
|
||||
// inherit that verified flag and start building form URLs on a name whose
|
||||
// CNAME still points at the source instance. Clear it whenever the domain
|
||||
// itself moved; the hourly sweep re-earns it once DNS points here.
|
||||
for _, c := range cols {
|
||||
if c != "forms_domain" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE organizations
|
||||
SET forms_domain_verified = false, forms_domain_verified_at = NULL
|
||||
WHERE id = $1
|
||||
`, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orgMergeExcluded are the organization columns an archive may never set.
|
||||
@@ -561,6 +584,14 @@ var orgMergeExcluded = func() map[string]bool {
|
||||
"created_at": true,
|
||||
"deletion_scheduled_at": true,
|
||||
"deletion_scheduled_for": true,
|
||||
// forms_domain itself travels: it is the customer's own name and they
|
||||
// will want it on the destination. The verdict must not, because it
|
||||
// was reached against the SOURCE instance's forms host. Importing it
|
||||
// verified would build every form URL on a name still pointing at the
|
||||
// old install. Left unverified, links fall back to the shared host and
|
||||
// the hourly sweep re-verifies once the customer repoints the record.
|
||||
"forms_domain_verified": true,
|
||||
"forms_domain_verified_at": true,
|
||||
}
|
||||
for name := range OrgRiskColumns {
|
||||
out[name] = true
|
||||
|
||||
@@ -317,6 +317,41 @@ var Tables = []Table{
|
||||
Name: "campaign_folders", Group: models.OrgDataGroupCampaigns,
|
||||
Scope: `campaign_id IN ` + orgCampaigns,
|
||||
},
|
||||
{
|
||||
// The forms tables live below campaigns despite belonging to the
|
||||
// Contacts group: each carries a campaign_id foreign key, and Tables is
|
||||
// applied top to bottom, so importing them earlier would hit a missing
|
||||
// campaign when both groups travel together.
|
||||
Name: "forms", Group: models.OrgDataGroupContacts,
|
||||
Scope: scopeOrg,
|
||||
Note: "public_id travels so installed embed codes keep working after a move; " +
|
||||
"campaign_id is a nullable crossing the importer blanks when campaigns stay behind.",
|
||||
Blobs: []BlobColumn{
|
||||
{Column: "logo_url", Kind: BlobKindPublicURL},
|
||||
{Column: "cover_url", Kind: BlobKindPublicURL},
|
||||
{Column: "background_url", Kind: BlobKindPublicURL},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "form_categories", Group: models.OrgDataGroupContacts,
|
||||
Scope: `form_id IN (SELECT id FROM forms WHERE organization_id = $1)`,
|
||||
},
|
||||
{
|
||||
Name: "form_submissions", Group: models.OrgDataGroupContacts,
|
||||
Scope: scopeOrg,
|
||||
},
|
||||
{
|
||||
Name: "form_links", Group: models.OrgDataGroupContacts,
|
||||
Scope: scopeOrg,
|
||||
Note: "row ids are the tokens inside already-sent personalized URLs, so they travel verbatim; " +
|
||||
"campaign_id is a nullable crossing the importer blanks when campaigns stay behind.",
|
||||
},
|
||||
{
|
||||
// Kept in Contacts (not Events) because form_id is a NOT NULL crossing
|
||||
// into forms; the 180-day retention bounds the volume.
|
||||
Name: "form_events", Group: models.OrgDataGroupContacts,
|
||||
Scope: scopeOrg,
|
||||
},
|
||||
{
|
||||
Name: "sequences", Group: models.OrgDataGroupCampaigns,
|
||||
Scope: scopeOrg,
|
||||
|
||||
@@ -69,6 +69,10 @@ const (
|
||||
SyncFloodPerHour = 5_000 // new live messages observed in one hour that mark a mailbox as flooding
|
||||
SyncThrottleEscalationDays = 3 // throttled UTC days out of the last 7 that deactivate a mailbox
|
||||
|
||||
// Forms. Funnel events feed analytics ranges up to 90 days; the fixed
|
||||
// retention window keeps double coverage without a per-org setting.
|
||||
FormEventsRetentionDays = 180
|
||||
|
||||
// Sequences. Empty by default so the editor shows a smart, position-based
|
||||
// label (e.g. "Email 1") until the user names the step themselves.
|
||||
SequenceDefaultName = ""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -32,3 +33,57 @@ func GetPasswordResetURL(sessionToken string) string {
|
||||
func GetInviteURL(token string) string {
|
||||
return AppBaseURL() + "/invite?token=" + url.QueryEscape(token)
|
||||
}
|
||||
|
||||
// FormsBaseURL is the origin hosted form pages are served from: FORMS_DOMAIN,
|
||||
// the host routed to the forms service (cmd/forms). Empty when unset — the
|
||||
// pages do not live on the API origin, so there is nothing to fall back to,
|
||||
// and a share link pointing at the wrong process is worse than none.
|
||||
func FormsBaseURL() string {
|
||||
if host := strings.TrimSpace(os.Getenv("FORMS_DOMAIN")); host != "" {
|
||||
host = strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://")
|
||||
host = strings.TrimRight(host, "/")
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FormsHostname is the bare host this install serves forms on. It is the
|
||||
// CNAME target a customer points their own forms subdomain at.
|
||||
func FormsHostname() string {
|
||||
return hostWithoutPort(NormalizeTrackingHost(FormsBaseURL()))
|
||||
}
|
||||
|
||||
// FormURLOn builds the hosted page URL on a specific host, which is how a
|
||||
// verified custom forms domain replaces the shared one. An empty host falls
|
||||
// back to this install's own forms base.
|
||||
func FormURLOn(host, publicID string) string {
|
||||
host = NormalizeTrackingHost(host)
|
||||
if host == "" {
|
||||
return GetFormURL(publicID)
|
||||
}
|
||||
scheme := "https"
|
||||
if name, port, err := net.SplitHostPort(host); err == nil {
|
||||
if port != "" && port != "443" {
|
||||
scheme = "http"
|
||||
}
|
||||
if name == "localhost" || strings.HasSuffix(name, ".localhost") {
|
||||
scheme = "http"
|
||||
}
|
||||
} else if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
scheme = "http"
|
||||
}
|
||||
return scheme + "://" + host + "/f/" + url.PathEscape(publicID)
|
||||
}
|
||||
|
||||
// GetFormURL is the hosted page for one form; empty when no base is known.
|
||||
func GetFormURL(publicID string) string {
|
||||
base := FormsBaseURL()
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
return base + "/f/" + url.PathEscape(publicID)
|
||||
}
|
||||
|
||||
@@ -93,3 +93,10 @@ func providerOr(env, def string) string {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// TurnstileSiteKey is the public widget key the hosted form page renders
|
||||
// with. Frontends carry their own copy (TURNSTILE_KEY); this one exists
|
||||
// because the server-rendered page cannot read the SPA's runtime config.
|
||||
func TurnstileSiteKey() string {
|
||||
return strings.TrimSpace(os.Getenv("TURNSTILE_SITE_KEY"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package formserver
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ipLimiter is a fixed-window in-memory submission limiter, the same shape
|
||||
// the tracking service uses for pixels. Budgets are per forms-service
|
||||
// instance; replicas each carry their own window, which only ever errs
|
||||
// toward letting a real visitor through.
|
||||
type ipLimiter struct {
|
||||
mu sync.Mutex
|
||||
limit int
|
||||
window time.Duration
|
||||
seen map[string]*ipWindow
|
||||
}
|
||||
|
||||
type ipWindow struct {
|
||||
n int
|
||||
reset time.Time
|
||||
}
|
||||
|
||||
// ipLimiterMax bounds the map against source-address spray; sweeping first,
|
||||
// then dropping the table, costs a reset budget, not unbounded memory.
|
||||
const ipLimiterMax = 100_000
|
||||
|
||||
func newIPLimiter(limit int, window time.Duration) *ipLimiter {
|
||||
return &ipLimiter{limit: limit, window: window, seen: map[string]*ipWindow{}}
|
||||
}
|
||||
|
||||
func (l *ipLimiter) Allow(ip string) bool {
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
w, ok := l.seen[ip]
|
||||
if !ok || now.After(w.reset) {
|
||||
if len(l.seen) >= ipLimiterMax {
|
||||
for k, e := range l.seen {
|
||||
if now.After(e.reset) {
|
||||
delete(l.seen, k)
|
||||
}
|
||||
}
|
||||
if len(l.seen) >= ipLimiterMax {
|
||||
l.seen = map[string]*ipWindow{}
|
||||
}
|
||||
}
|
||||
l.seen[ip] = &ipWindow{n: 1, reset: now.Add(l.window)}
|
||||
return true
|
||||
}
|
||||
w.n++
|
||||
return w.n <= l.limit
|
||||
}
|
||||
|
||||
// ttlSet remembers keys for a fixed duration; Add reports whether the key was
|
||||
// new. It backs the per-visitor view dedupe.
|
||||
type ttlSet struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
seen map[string]time.Time
|
||||
}
|
||||
|
||||
const ttlSetMax = 200_000
|
||||
|
||||
func newTTLSet(ttl time.Duration) *ttlSet {
|
||||
return &ttlSet{ttl: ttl, seen: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
func (s *ttlSet) Add(key string) bool {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if exp, ok := s.seen[key]; ok && now.Before(exp) {
|
||||
return false
|
||||
}
|
||||
if len(s.seen) >= ttlSetMax {
|
||||
for k, exp := range s.seen {
|
||||
if now.After(exp) {
|
||||
delete(s.seen, k)
|
||||
}
|
||||
}
|
||||
if len(s.seen) >= ttlSetMax {
|
||||
s.seen = map[string]time.Time{}
|
||||
}
|
||||
}
|
||||
s.seen[key] = now.Add(s.ttl)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package formserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/formwire"
|
||||
)
|
||||
|
||||
var errFormNotFound = errors.New("formserver: form not found")
|
||||
|
||||
const (
|
||||
// Short positive TTL: an unpublish takes effect within it, and one busy
|
||||
// form costs the backend at most a few requests a minute.
|
||||
formCacheTTL = 15 * time.Second
|
||||
formNegCacheTTL = 30 * time.Second
|
||||
// formCacheMax bounds the map against public-id spray; real instances
|
||||
// hold a few hundred forms at most.
|
||||
formCacheMax = 4096
|
||||
)
|
||||
|
||||
// backendClient is the forms service's only dependency: the backend's
|
||||
// internal API, authenticated with the same bearer token the workers and the
|
||||
// tracking service use.
|
||||
type backendClient struct {
|
||||
base string
|
||||
token string
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]cacheEntry
|
||||
}
|
||||
|
||||
// cacheEntry with a nil form is a negative entry: the id is known missing.
|
||||
type cacheEntry struct {
|
||||
form *formwire.PublicForm
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func newBackendClient(base, token string) *backendClient {
|
||||
return &backendClient{
|
||||
base: base,
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
cache: map[string]cacheEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backendClient) formURL(publicID, suffix string) string {
|
||||
return b.base + "/api/v1/internal/forms/" + url.PathEscape(publicID) + suffix
|
||||
}
|
||||
|
||||
func (b *backendClient) do(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("Authorization", "Bearer "+b.token)
|
||||
return b.http.Do(req)
|
||||
}
|
||||
|
||||
func (b *backendClient) cached(publicID string) (cacheEntry, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
e, ok := b.cache[publicID]
|
||||
if !ok || time.Now().After(e.expires) {
|
||||
return cacheEntry{}, false
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
func (b *backendClient) store(publicID string, f *formwire.PublicForm, ttl time.Duration) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if len(b.cache) >= formCacheMax {
|
||||
now := time.Now()
|
||||
for k, e := range b.cache {
|
||||
if now.After(e.expires) {
|
||||
delete(b.cache, k)
|
||||
}
|
||||
}
|
||||
// Still full after sweeping means an id spray; dropping the window
|
||||
// costs a few extra backend hits, holding it costs unbounded memory.
|
||||
if len(b.cache) >= formCacheMax {
|
||||
b.cache = map[string]cacheEntry{}
|
||||
}
|
||||
}
|
||||
b.cache[publicID] = cacheEntry{form: f, expires: time.Now().Add(ttl)}
|
||||
}
|
||||
|
||||
// Form resolves a published form, TTL-cached. errFormNotFound for unknown or
|
||||
// unpublished ids; any other error means the backend was unreachable.
|
||||
func (b *backendClient) Form(ctx context.Context, publicID string) (*formwire.PublicForm, error) {
|
||||
if e, ok := b.cached(publicID); ok {
|
||||
if e.form == nil {
|
||||
return nil, errFormNotFound
|
||||
}
|
||||
return e.form, nil
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.formURL(publicID, ""), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := b.do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
var f formwire.PublicForm
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.store(publicID, &f, formCacheTTL)
|
||||
return &f, nil
|
||||
case http.StatusNotFound:
|
||||
b.store(publicID, nil, formNegCacheTTL)
|
||||
return nil, errFormNotFound
|
||||
default:
|
||||
return nil, fmt.Errorf("formserver: backend returned %d for form fetch", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// submitOutcome is the backend's verdict on one submission. Exactly one of
|
||||
// Result, Rejected or NotFound is meaningful.
|
||||
type submitOutcome struct {
|
||||
Result *formwire.SubmitResult
|
||||
Rejected string
|
||||
NotFound bool
|
||||
}
|
||||
|
||||
// Submit forwards a visitor's answers. An error return means the backend was
|
||||
// unreachable, never that the submission was invalid.
|
||||
func (b *backendClient) Submit(ctx context.Context, publicID string, in *formwire.SubmitRequest) (*submitOutcome, error) {
|
||||
body, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.formURL(publicID, "/submissions"), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := b.do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
var res formwire.SubmitResult
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &submitOutcome{Result: &res}, nil
|
||||
case http.StatusNotFound:
|
||||
return &submitOutcome{NotFound: true}, nil
|
||||
case http.StatusBadRequest:
|
||||
var rej formwire.SubmitError
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&rej); err != nil || rej.Message == "" {
|
||||
rej.Message = "The submission could not be processed."
|
||||
}
|
||||
return &submitOutcome{Rejected: rej.Message}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("formserver: backend returned %d for submit", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// FormWithToken resolves a form together with its ?t= prefill. Deliberately
|
||||
// uncached: per-contact prefill must never be served to another visitor, and
|
||||
// the render-token gate plus the event budget bound the extra backend hits.
|
||||
func (b *backendClient) FormWithToken(ctx context.Context, publicID, linkToken string) (*formwire.PublicForm, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.formURL(publicID, "?t="+url.QueryEscape(linkToken)), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := b.do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
var f formwire.PublicForm
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
case http.StatusNotFound:
|
||||
b.store(publicID, nil, formNegCacheTTL)
|
||||
return nil, errFormNotFound
|
||||
default:
|
||||
return nil, fmt.Errorf("formserver: backend returned %d for form fetch", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// RecordEvent forwards one funnel event; best-effort, the page never waits.
|
||||
func (b *backendClient) RecordEvent(publicID string, ev *formwire.EventRequest) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
body, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.formURL(publicID, "/events"), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := b.do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package formserver
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// The embed loader ships inside the binary, like the tracking snippet does.
|
||||
//
|
||||
//go:embed static/forms-embed.js
|
||||
var formsEmbedJS []byte
|
||||
|
||||
// Terminal pages for a shell request that cannot reach the app. The React
|
||||
// app renders the same copy for its own in-app failure states.
|
||||
|
||||
func formNotFoundPage(c *gin.Context) {
|
||||
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>Form not found</title></head><body style="font-family:system-ui,-apple-system,sans-serif;max-width:26rem;margin:5rem auto;padding:0 1rem;color:#0f172a;text-align:center"><h1 style="font-size:1.1rem;margin:0 0 .5rem">This form is no longer available</h1><p style="font-size:.9rem;color:#475569">It may have been unpublished or removed.</p></body></html>`))
|
||||
}
|
||||
|
||||
func formUnavailablePage(c *gin.Context) {
|
||||
c.Data(http.StatusServiceUnavailable, "text/html; charset=utf-8", []byte(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>Form unavailable</title></head><body style="font-family:system-ui,-apple-system,sans-serif;max-width:26rem;margin:5rem auto;padding:0 1rem;color:#0f172a;text-align:center"><h1 style="font-size:1.1rem;margin:0 0 .5rem">This form is temporarily unavailable</h1><p style="font-size:.9rem;color:#475569">Please try again in a moment.</p></body></html>`))
|
||||
}
|
||||
|
||||
// setFormFrameHeaders enforces the embed allowlist in the browser: with
|
||||
// domains set, only those sites (and the form's own origin) may frame it.
|
||||
// This is why the shell must come from this process and not a dumb file
|
||||
// server: the header is per-form.
|
||||
func setFormFrameHeaders(c *gin.Context, allowedDomains []string) {
|
||||
if len(allowedDomains) == 0 {
|
||||
return
|
||||
}
|
||||
sources := make([]string, 0, len(allowedDomains)*2+1)
|
||||
sources = append(sources, "'self'")
|
||||
for _, d := range allowedDomains {
|
||||
sources = append(sources, d, "*."+d)
|
||||
}
|
||||
c.Header("Content-Security-Policy", "frame-ancestors "+strings.Join(sources, " "))
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Package formserver is the public face of hosted forms: a standalone HTTP
|
||||
// service (cmd/forms) that serves the TanStack form app (forms/ at the repo
|
||||
// root), its per-form page shells, the embed loader, and a same-origin JSON
|
||||
// API that forwards to the backend over the internal API. It exists so
|
||||
// nothing customer-facing answers on the API origin and form traffic scales
|
||||
// on its own box. Like the workers and the tracking service, it holds no
|
||||
// database: the backend owns validation, contacts and counters; this process
|
||||
// owns the shell (with its per-form CSP), caching and the per-visitor abuse
|
||||
// checks only it can see.
|
||||
package formserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/warmbly/warmbly/internal/formwire"
|
||||
)
|
||||
|
||||
// formMaxSubmitBytes caps a public submit body; the largest legitimate form
|
||||
// is dozens of short answers.
|
||||
const formMaxSubmitBytes = 64 << 10
|
||||
|
||||
const (
|
||||
submitWindow = 10 * time.Minute
|
||||
submitDefaultLimit = 30
|
||||
viewDedupeTTL = 30 * time.Minute
|
||||
maxSourceURLLen = 2048
|
||||
// eventWindowLimit bounds funnel events per source IP per submitWindow; a
|
||||
// long multi-page session emits a couple dozen.
|
||||
eventWindowLimit = 120
|
||||
// formMaxEventBytes caps an event body; it is a handful of short fields.
|
||||
formMaxEventBytes = 8 << 10
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// BackendURL and InternalToken reach the backend's /api/v1/internal
|
||||
// endpoints, the service's only dependency.
|
||||
BackendURL string
|
||||
InternalToken string
|
||||
// StaticDir is the built forms app (forms/dist): index.html is the page
|
||||
// shell, assets/ the hashed bundles.
|
||||
StaticDir string
|
||||
// SubmitLimit is submissions per source IP per 10 minutes; 0 means the
|
||||
// default of 30.
|
||||
SubmitLimit int
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
client *backendClient
|
||||
views *ttlSet
|
||||
limits *ipLimiter
|
||||
events *ipLimiter
|
||||
renderKey []byte
|
||||
shell []byte
|
||||
assetsDir string
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Server, error) {
|
||||
if cfg.BackendURL == "" {
|
||||
return nil, errors.New("formserver: BackendURL is required")
|
||||
}
|
||||
if cfg.InternalToken == "" {
|
||||
return nil, errors.New("formserver: InternalToken is required")
|
||||
}
|
||||
if cfg.StaticDir == "" {
|
||||
return nil, errors.New("formserver: StaticDir is required")
|
||||
}
|
||||
shell, err := os.ReadFile(filepath.Join(cfg.StaticDir, "index.html"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("formserver: cannot read %s/index.html (run `pnpm build` in forms/): %w", cfg.StaticDir, err)
|
||||
}
|
||||
limit := cfg.SubmitLimit
|
||||
if limit <= 0 {
|
||||
limit = submitDefaultLimit
|
||||
}
|
||||
return &Server{
|
||||
client: newBackendClient(strings.TrimRight(cfg.BackendURL, "/"), cfg.InternalToken),
|
||||
views: newTTLSet(viewDedupeTTL),
|
||||
limits: newIPLimiter(limit, submitWindow),
|
||||
events: newIPLimiter(eventWindowLimit, submitWindow),
|
||||
renderKey: renderKey(cfg.InternalToken),
|
||||
shell: shell,
|
||||
assetsDir: filepath.Join(cfg.StaticDir, "assets"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Router(trustedProxies []string) (*gin.Engine, error) {
|
||||
r := gin.Default()
|
||||
// Same posture as the backend: trust no proxy unless the operator names
|
||||
// it, so a forged X-Forwarded-For cannot dodge the submit limiter.
|
||||
if len(trustedProxies) > 0 {
|
||||
if err := r.SetTrustedProxies(trustedProxies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := r.SetTrustedProxies(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
|
||||
r.GET("/forms.js", s.ServeFormsEmbedJS)
|
||||
r.GET("/f/:publicID", s.ServeFormShell)
|
||||
|
||||
// Hashed filenames, so the bundles are immutable by construction.
|
||||
assets := r.Group("/assets", func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
})
|
||||
assets.Static("/", s.assetsDir)
|
||||
|
||||
// Every JSON call needs the render token minted with the page shell, so
|
||||
// scripted clients that never load the page get nothing.
|
||||
api := r.Group("/api", s.requireRenderToken)
|
||||
api.GET("/forms/:publicID", s.GetPublicForm)
|
||||
api.POST("/forms/:publicID/submit", s.SubmitForm)
|
||||
api.POST("/forms/:publicID/events", s.RecordFormEvent)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// requireRenderToken rejects API calls whose X-Warmbly-Render token is
|
||||
// missing, forged, expired or minted for another form. The app treats the
|
||||
// stale_page error as "refresh this tab".
|
||||
func (s *Server) requireRenderToken(c *gin.Context) {
|
||||
token := c.GetHeader("X-Warmbly-Render")
|
||||
if token == "" || !verifyRenderToken(s.renderKey, c.Param("publicID"), token, time.Now()) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "stale_page"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// fetchForm resolves a form or reports how the lookup ended.
|
||||
func (s *Server) fetchForm(c *gin.Context) (*formwire.PublicForm, error) {
|
||||
publicID := c.Param("publicID")
|
||||
if publicID == "" || len(publicID) > 64 {
|
||||
return nil, errFormNotFound
|
||||
}
|
||||
return s.client.Form(c.Request.Context(), publicID)
|
||||
}
|
||||
|
||||
// wfTokenPlaceholder is the meta tag forms/index.html ships; the shell serve
|
||||
// stamps the real render token into it.
|
||||
const wfTokenPlaceholder = `<meta name="wf-token" content="" />`
|
||||
|
||||
// ServeFormShell serves the app shell for a published form. Public and
|
||||
// unauthenticated: the unguessable public id is the capability. The form is
|
||||
// resolved before serving so unknown ids 404 like any dead link, the
|
||||
// per-form embed CSP rides on the document the browser frames, and the shell
|
||||
// carries the render token the JSON API requires.
|
||||
func (s *Server) ServeFormShell(c *gin.Context) {
|
||||
f, err := s.fetchForm(c)
|
||||
if errors.Is(err, errFormNotFound) {
|
||||
formNotFoundPage(c)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
formUnavailablePage(c)
|
||||
return
|
||||
}
|
||||
setFormFrameHeaders(c, f.AllowedDomains)
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
token := mintRenderToken(s.renderKey, f.PublicID, time.Now())
|
||||
stamped := []byte(`<meta name="wf-token" content="` + token + `" />`)
|
||||
shell := bytes.Replace(s.shell, []byte(wfTokenPlaceholder), stamped, 1)
|
||||
if bytes.Equal(shell, s.shell) {
|
||||
// Older build without the placeholder: inject before </head>.
|
||||
shell = bytes.Replace(s.shell, []byte("</head>"), append(stamped, []byte("</head>")...), 1)
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", shell)
|
||||
}
|
||||
|
||||
// isPrefetch reports whether the request is a prefetch or link preview, so
|
||||
// they never count toward funnel stats.
|
||||
func isPrefetch(c *gin.Context) bool {
|
||||
for _, header := range []string{"Sec-Purpose", "Purpose", "X-Purpose", "X-Moz"} {
|
||||
if v := c.GetHeader(header); v != "" && strings.Contains(strings.ToLower(v), "prefetch") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetPublicForm hands the app the form definition: what the visitor may see
|
||||
// and nothing more (the embed allowlist stays server-side with the CSP). A
|
||||
// ?t= ticket bypasses the cache so prefill never leaks across visitors.
|
||||
func (s *Server) GetPublicForm(c *gin.Context) {
|
||||
var f *formwire.PublicForm
|
||||
var err error
|
||||
if token := c.Query("t"); token != "" && len(token) <= 64 {
|
||||
f, err = s.client.FormWithToken(c.Request.Context(), c.Param("publicID"), token)
|
||||
} else {
|
||||
f, err = s.fetchForm(c)
|
||||
}
|
||||
if errors.Is(err, errFormNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "unavailable"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"public_id": f.PublicID,
|
||||
"name": f.Name,
|
||||
"fields": f.Fields,
|
||||
"design": f.Design,
|
||||
"logo_url": f.LogoURL,
|
||||
"cover_url": f.CoverURL,
|
||||
"background_url": f.BackgroundURL,
|
||||
"captcha_site_key": f.CaptchaSiteKey,
|
||||
"prefill": f.Prefill,
|
||||
"link_token": f.LinkToken,
|
||||
})
|
||||
}
|
||||
|
||||
// eventBody is what the app beacons; keep in sync with forms/src/events.ts.
|
||||
type eventBody struct {
|
||||
Type string `json:"type"`
|
||||
PageIndex int `json:"page_index"`
|
||||
PagesTotal int `json:"pages_total"`
|
||||
VisitorKey string `json:"visitor_key"`
|
||||
SourceURL string `json:"source_url"`
|
||||
LinkToken string `json:"link_token"`
|
||||
}
|
||||
|
||||
// RecordFormEvent accepts a funnel beacon, applies the checks only this
|
||||
// process can make (prefetch filter, per-IP budget, view dedupe) and
|
||||
// forwards it for enrichment. Always 204: a beacon never surfaces errors.
|
||||
func (s *Server) RecordFormEvent(c *gin.Context) {
|
||||
if isPrefetch(c) {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
ip := c.ClientIP()
|
||||
if ip != "" && !s.events.Allow(ip) {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, formMaxEventBytes)
|
||||
var body eventBody
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&body); err != nil {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
publicID := c.Param("publicID")
|
||||
if body.Type == "view" && !s.views.Add(publicID+":"+truncateStr(body.VisitorKey, 64)) {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
sourceURL := body.SourceURL
|
||||
if sourceURL == "" {
|
||||
sourceURL = c.GetHeader("Referer")
|
||||
}
|
||||
if len(sourceURL) > maxSourceURLLen {
|
||||
sourceURL = sourceURL[:maxSourceURLLen]
|
||||
}
|
||||
go s.client.RecordEvent(publicID, &formwire.EventRequest{
|
||||
Type: body.Type,
|
||||
PageIndex: body.PageIndex,
|
||||
PagesTotal: body.PagesTotal,
|
||||
VisitorKey: truncateStr(body.VisitorKey, 64),
|
||||
SourceURL: sourceURL,
|
||||
LinkToken: truncateStr(body.LinkToken, 64),
|
||||
RemoteIP: ip,
|
||||
UserAgent: truncateStr(c.GetHeader("User-Agent"), 512),
|
||||
})
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// submitBody is what the app posts; keep in sync with forms/src/api.ts.
|
||||
type submitBody struct {
|
||||
Answers map[string][]string `json:"answers"`
|
||||
// Website is the honeypot value; a human never fills it.
|
||||
Website string `json:"website"`
|
||||
WT int64 `json:"_wt"`
|
||||
CaptchaToken string `json:"captcha_token"`
|
||||
SourceURL string `json:"source_url"`
|
||||
LinkToken string `json:"link_token"`
|
||||
VisitorKey string `json:"visitor_key"`
|
||||
}
|
||||
|
||||
// SubmitForm accepts a public submission, runs the checks only this process
|
||||
// can make (per-IP budget, body cap) and forwards the rest to the backend.
|
||||
// Same-origin with the app, so there is no CORS surface.
|
||||
func (s *Server) SubmitForm(c *gin.Context) {
|
||||
fail := func(status int, msg string) {
|
||||
c.JSON(status, gin.H{"error": "form_submit_failed", "message": msg})
|
||||
}
|
||||
|
||||
if ip := c.ClientIP(); ip != "" && !s.limits.Allow(ip) {
|
||||
c.Header("Retry-After", fmt.Sprintf("%d", int(submitWindow.Seconds())))
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": "Too many submissions from this address. Try again later.",
|
||||
"code": "rate_limit_exceeded",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, formMaxSubmitBytes)
|
||||
var body submitBody
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&body); err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
fail(http.StatusRequestEntityTooLarge, "The submission is too large.")
|
||||
return
|
||||
}
|
||||
fail(http.StatusBadRequest, "The submission could not be read.")
|
||||
return
|
||||
}
|
||||
|
||||
sourceURL := body.SourceURL
|
||||
if sourceURL == "" {
|
||||
sourceURL = c.GetHeader("Referer")
|
||||
}
|
||||
if len(sourceURL) > maxSourceURLLen {
|
||||
sourceURL = sourceURL[:maxSourceURLLen]
|
||||
}
|
||||
|
||||
req := &formwire.SubmitRequest{
|
||||
Answers: body.Answers,
|
||||
RemoteIP: c.ClientIP(),
|
||||
SourceURL: sourceURL,
|
||||
CaptchaToken: body.CaptchaToken,
|
||||
HoneypotFilled: strings.TrimSpace(body.Website) != "",
|
||||
RenderedAt: body.WT,
|
||||
LinkToken: truncateStr(body.LinkToken, 64),
|
||||
VisitorKey: truncateStr(body.VisitorKey, 64),
|
||||
}
|
||||
|
||||
out, err := s.client.Submit(c.Request.Context(), c.Param("publicID"), req)
|
||||
if err != nil {
|
||||
fail(http.StatusBadGateway, "Something went wrong. Please try again.")
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case out.NotFound:
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not_found"})
|
||||
return
|
||||
case out.Rejected != "":
|
||||
fail(http.StatusBadRequest, out.Rejected)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out.Result)
|
||||
}
|
||||
|
||||
// ServeFormsEmbedJS serves the embed loader. Cached for an hour, mirroring
|
||||
// the tracking service's tracking.js.
|
||||
func (s *Server) ServeFormsEmbedJS(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Data(http.StatusOK, "application/javascript; charset=utf-8", formsEmbedJS)
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package formserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/api/handler"
|
||||
"github.com/warmbly/warmbly/internal/app/form"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// stubFormService backs the REAL internal handlers, so this file tests the
|
||||
// whole wire: forms service -> HTTP -> backend handler -> service call.
|
||||
type stubFormService struct {
|
||||
form.Service // unimplemented methods panic; the internal endpoints use only these
|
||||
|
||||
mu sync.Mutex
|
||||
form *models.Form
|
||||
answers map[string][]string
|
||||
meta form.SubmitMeta
|
||||
events []form.EventInput
|
||||
reject *errx.Error
|
||||
|
||||
// linkToken maps to linkContact when ResolveLink is asked about it.
|
||||
linkToken string
|
||||
linkContact uuid.UUID
|
||||
prefill map[string]string
|
||||
}
|
||||
|
||||
func (s *stubFormService) PublicForm(_ context.Context, publicID string) (*models.Form, *errx.Error) {
|
||||
if s.form == nil || publicID != s.form.PublicID {
|
||||
return nil, errx.New(errx.NotFound, "form not found")
|
||||
}
|
||||
return s.form, nil
|
||||
}
|
||||
|
||||
func (s *stubFormService) RecordEvent(_ context.Context, publicID string, in form.EventInput) *errx.Error {
|
||||
if s.form == nil || publicID != s.form.PublicID {
|
||||
return errx.New(errx.NotFound, "form not found")
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.events = append(s.events, in)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubFormService) ResolveLink(_ context.Context, f *models.Form, token string) (*models.FormLink, map[string]string) {
|
||||
if token == "" || token != s.linkToken || f == nil || f.ID != s.form.ID {
|
||||
return nil, nil
|
||||
}
|
||||
return &models.FormLink{ID: uuid.MustParse(token), FormID: f.ID, ContactID: s.linkContact}, s.prefill
|
||||
}
|
||||
|
||||
func (s *stubFormService) Submit(_ context.Context, publicID string, answers map[string][]string, meta form.SubmitMeta) (*form.SubmitResult, *errx.Error) {
|
||||
if s.form == nil || publicID != s.form.PublicID {
|
||||
return nil, errx.New(errx.NotFound, "form not found")
|
||||
}
|
||||
if s.reject != nil {
|
||||
return nil, s.reject
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.answers, s.meta = answers, meta
|
||||
s.mu.Unlock()
|
||||
return &form.SubmitResult{Message: "Thanks!"}, nil
|
||||
}
|
||||
|
||||
func (s *stubFormService) eventCount(t string) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n := 0
|
||||
for _, e := range s.events {
|
||||
if e.Type == t {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
const shellMarker = `<div id="root"></div>`
|
||||
|
||||
func writeStaticDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
html := `<!doctype html><html><head><meta name="wf-token" content="" /></head><body>` + shellMarker + `</body></html>`
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(html), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "assets", "index-abc123.js"), []byte("console.log(1)"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, submitLimit int) (*stubFormService, *Server, *gin.Engine) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
stub := &stubFormService{form: &models.Form{
|
||||
ID: uuid.New(),
|
||||
PublicID: "pubtest123",
|
||||
Name: "Demo request",
|
||||
Fields: []models.FormField{
|
||||
{ID: "email", Type: models.FormFieldEmail, Label: "Work email", Required: true, MapTo: "email"},
|
||||
},
|
||||
AllowedDomains: []string{"example.com"},
|
||||
}}
|
||||
h := &handler.Handler{FormService: stub}
|
||||
backend := gin.New()
|
||||
backend.GET("/api/v1/internal/forms/:publicID", h.InternalGetPublicForm)
|
||||
backend.POST("/api/v1/internal/forms/:publicID/events", h.InternalRecordFormEvent)
|
||||
backend.POST("/api/v1/internal/forms/:publicID/submissions", h.InternalSubmitForm)
|
||||
ts := httptest.NewServer(backend)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
srv, err := New(Config{BackendURL: ts.URL, InternalToken: "test-token", StaticDir: writeStaticDir(t), SubmitLimit: submitLimit})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
r, err := srv.Router(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("router: %v", err)
|
||||
}
|
||||
return stub, srv, r
|
||||
}
|
||||
|
||||
func get(r *gin.Engine, path, renderToken string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if renderToken != "" {
|
||||
req.Header.Set("X-Warmbly-Render", renderToken)
|
||||
}
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func post(r *gin.Engine, path, renderToken string, body any) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(raw)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if renderToken != "" {
|
||||
req.Header.Set("X-Warmbly-Render", renderToken)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func mintFor(srv *Server, publicID string) string {
|
||||
return mintRenderToken(srv.renderKey, publicID, time.Now())
|
||||
}
|
||||
|
||||
func TestFormServerServesShellWithRenderToken(t *testing.T) {
|
||||
_, _, r := newFixture(t, 0)
|
||||
|
||||
w := get(r, "/f/pubtest123", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("shell status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), shellMarker) {
|
||||
t.Fatal("shell did not serve the built app")
|
||||
}
|
||||
if strings.Contains(w.Body.String(), `name="wf-token" content=""`) {
|
||||
t.Fatal("render token was not stamped into the shell")
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `name="wf-token" content="`) {
|
||||
t.Fatal("shell is missing the render token meta")
|
||||
}
|
||||
if csp := w.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "example.com") {
|
||||
t.Fatalf("embed allowlist not enforced: %q", csp)
|
||||
}
|
||||
if w := get(r, "/f/unknownid", ""); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown id status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormServerRequiresRenderToken(t *testing.T) {
|
||||
_, srv, r := newFixture(t, 0)
|
||||
|
||||
if w := get(r, "/api/forms/pubtest123", ""); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("missing token status %d, want 403", w.Code)
|
||||
}
|
||||
if w := get(r, "/api/forms/pubtest123", "garbage"); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("forged token status %d, want 403", w.Code)
|
||||
}
|
||||
// A token minted for another form must not open this one.
|
||||
if w := get(r, "/api/forms/pubtest123", mintFor(srv, "otherform")); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-form token status %d, want 403", w.Code)
|
||||
}
|
||||
expired := mintRenderToken(srv.renderKey, "pubtest123", time.Now().Add(-2*renderTokenTTL))
|
||||
if w := get(r, "/api/forms/pubtest123", expired); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expired token status %d, want 403", w.Code)
|
||||
}
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
w := get(r, "/api/forms/pubtest123", "garbage")
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body.Error != "stale_page" {
|
||||
t.Fatalf("403 body: %s", w.Body.String())
|
||||
}
|
||||
if w := get(r, "/api/forms/pubtest123", mintFor(srv, "pubtest123")); w.Code != http.StatusOK {
|
||||
t.Fatalf("valid token status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormServerServesFormJSONAndAssets(t *testing.T) {
|
||||
_, srv, r := newFixture(t, 0)
|
||||
token := mintFor(srv, "pubtest123")
|
||||
|
||||
w := get(r, "/api/forms/pubtest123", token)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("form json status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
PublicID string `json:"public_id"`
|
||||
Name string `json:"name"`
|
||||
Fields []models.FormField `json:"fields"`
|
||||
Allowed []string `json:"allowed_domains"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if res.Name != "Demo request" || len(res.Fields) != 1 || res.Fields[0].Label != "Work email" {
|
||||
t.Fatalf("form json: %s", w.Body.String())
|
||||
}
|
||||
if res.Allowed != nil {
|
||||
t.Fatal("embed allowlist must not reach the client")
|
||||
}
|
||||
if w := get(r, "/api/forms/unknownid", mintFor(srv, "unknownid")); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown id status %d", w.Code)
|
||||
}
|
||||
|
||||
a := get(r, "/assets/index-abc123.js", "")
|
||||
if a.Code != http.StatusOK || !strings.Contains(a.Header().Get("Cache-Control"), "immutable") {
|
||||
t.Fatalf("asset serving: %d %q", a.Code, a.Header().Get("Cache-Control"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormServerPrefillPassthrough(t *testing.T) {
|
||||
stub, srv, r := newFixture(t, 0)
|
||||
stub.linkToken = uuid.NewString()
|
||||
stub.linkContact = uuid.New()
|
||||
stub.prefill = map[string]string{"email": "jane@example.com"}
|
||||
|
||||
w := get(r, "/api/forms/pubtest123?t="+stub.linkToken, mintFor(srv, "pubtest123"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Prefill map[string]string `json:"prefill"`
|
||||
LinkToken string `json:"link_token"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if res.Prefill["email"] != "jane@example.com" || res.LinkToken != stub.linkToken {
|
||||
t.Fatalf("prefill did not cross the wire: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// A bogus token degrades to the anonymous payload, never an error.
|
||||
w = get(r, "/api/forms/pubtest123?t="+uuid.NewString(), mintFor(srv, "pubtest123"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("bogus token status %d", w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "jane@example.com") {
|
||||
t.Fatal("prefill leaked to an unidentified visitor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormServerRecordsEventsWithViewDedupe(t *testing.T) {
|
||||
stub, srv, r := newFixture(t, 0)
|
||||
token := mintFor(srv, "pubtest123")
|
||||
|
||||
view := map[string]any{"type": "view", "visitor_key": "vk-1", "pages_total": 2, "source_url": "https://example.com/pricing"}
|
||||
if w := post(r, "/api/forms/pubtest123/events", token, view); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("event status %d", w.Code)
|
||||
}
|
||||
// Same visitor again: deduped before it reaches the backend.
|
||||
post(r, "/api/forms/pubtest123/events", token, view)
|
||||
// Different visitor: counts.
|
||||
post(r, "/api/forms/pubtest123/events", token, map[string]any{"type": "view", "visitor_key": "vk-2"})
|
||||
// Page progress is never deduped.
|
||||
post(r, "/api/forms/pubtest123/events", token, map[string]any{"type": "page", "visitor_key": "vk-1", "page_index": 1, "pages_total": 2})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for (stub.eventCount("view") < 2 || stub.eventCount("page") < 1) && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := stub.eventCount("view"); got != 2 {
|
||||
t.Fatalf("view events forwarded = %d, want 2", got)
|
||||
}
|
||||
if got := stub.eventCount("page"); got != 1 {
|
||||
t.Fatalf("page events forwarded = %d, want 1", got)
|
||||
}
|
||||
// Forwarding is async, so find the view rather than assuming arrival order.
|
||||
stub.mu.Lock()
|
||||
var view1 *form.EventInput
|
||||
for i, e := range stub.events {
|
||||
if e.Type == "view" && e.VisitorKey == "vk-1" {
|
||||
view1 = &stub.events[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
stub.mu.Unlock()
|
||||
if view1 == nil {
|
||||
t.Fatal("the first view never reached the backend")
|
||||
}
|
||||
if view1.SourceURL != "https://example.com/pricing" || view1.RemoteIP == "" {
|
||||
t.Fatalf("event enrichment inputs lost: %+v", *view1)
|
||||
}
|
||||
|
||||
// Prefetches are dropped before the budget or the backend.
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/forms/pubtest123/events", strings.NewReader(`{"type":"view","visitor_key":"vk-3"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Warmbly-Render", token)
|
||||
req.Header.Set("Sec-Purpose", "prefetch")
|
||||
r.ServeHTTP(w, req)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := stub.eventCount("view"); got != 2 {
|
||||
t.Fatalf("prefetch view was forwarded (count %d)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormServerForwardsSubmission(t *testing.T) {
|
||||
stub, srv, r := newFixture(t, 0)
|
||||
stub.linkToken = uuid.NewString()
|
||||
stub.linkContact = uuid.New()
|
||||
|
||||
w := post(r, "/api/forms/pubtest123/submit", mintFor(srv, "pubtest123"), map[string]any{
|
||||
"answers": map[string][]string{"email": {"visitor@example.com"}},
|
||||
"website": "bot-filled", // honeypot: forwarded as a signal, not an answer
|
||||
"_wt": 1700000000,
|
||||
"link_token": stub.linkToken,
|
||||
"visitor_key": "vk-9",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("submit status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil || res.Message != "Thanks!" {
|
||||
t.Fatalf("submit response: %s", w.Body.String())
|
||||
}
|
||||
if got := stub.answers["email"]; len(got) != 1 || got[0] != "visitor@example.com" {
|
||||
t.Fatalf("answers did not cross the wire: %+v", stub.answers)
|
||||
}
|
||||
if !stub.meta.HoneypotFilled {
|
||||
t.Fatal("honeypot signal lost on the wire")
|
||||
}
|
||||
if stub.meta.RenderedAt.Unix() != 1700000000 {
|
||||
t.Fatalf("rendered-at lost on the wire: %v", stub.meta.RenderedAt)
|
||||
}
|
||||
if stub.meta.LinkToken != stub.linkToken || stub.meta.VisitorKey != "vk-9" {
|
||||
t.Fatalf("attribution lost on the wire: %+v", stub.meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormServerRelaysRejectionMessage(t *testing.T) {
|
||||
stub, srv, r := newFixture(t, 0)
|
||||
stub.reject = errx.New(errx.BadRequest, "Email is required.")
|
||||
|
||||
w := post(r, "/api/forms/pubtest123/submit", mintFor(srv, "pubtest123"), map[string]any{
|
||||
"answers": map[string][]string{"email": {""}},
|
||||
})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
var res struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil || res.Message != "Email is required." {
|
||||
t.Fatalf("rejection not relayed verbatim: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormServerSubmitRateLimit(t *testing.T) {
|
||||
_, srv, r := newFixture(t, 2)
|
||||
token := mintFor(srv, "pubtest123")
|
||||
payload := map[string]any{"answers": map[string][]string{"email": {"a@b.co"}}}
|
||||
for i := 0; i < 2; i++ {
|
||||
if w := post(r, "/api/forms/pubtest123/submit", token, payload); w.Code != http.StatusOK {
|
||||
t.Fatalf("submit %d status %d", i, w.Code)
|
||||
}
|
||||
}
|
||||
w := post(r, "/api/forms/pubtest123/submit", token, payload)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("third submit status %d, want 429", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/* Warmbly Forms embed. Renders auto-resizing iframes for every
|
||||
* <div data-warmbly-form="ID"> and wires popup triggers for every
|
||||
* element with data-warmbly-popup="ID". The form itself always runs on
|
||||
* the Warmbly origin, so the host page's CSS and scripts cannot break
|
||||
* it and no visitor data flows through the host page. */
|
||||
(function () {
|
||||
"use strict";
|
||||
if (window.__warmblyFormsLoaded) return;
|
||||
window.__warmblyFormsLoaded = true;
|
||||
|
||||
var script = document.currentScript;
|
||||
var base = "";
|
||||
if (script && script.src) {
|
||||
var a = document.createElement("a");
|
||||
a.href = script.src;
|
||||
base = a.protocol + "//" + a.host;
|
||||
}
|
||||
|
||||
function frameUrl(id) {
|
||||
return base + "/f/" + encodeURIComponent(id) + "?embed=1";
|
||||
}
|
||||
|
||||
function makeFrame(id) {
|
||||
var f = document.createElement("iframe");
|
||||
f.src = frameUrl(id);
|
||||
f.setAttribute("data-warmbly-frame", id);
|
||||
f.setAttribute("title", "Form");
|
||||
f.style.width = "100%";
|
||||
f.style.border = "0";
|
||||
f.style.display = "block";
|
||||
f.style.minHeight = "180px";
|
||||
f.style.background = "transparent";
|
||||
f.setAttribute("allowtransparency", "true");
|
||||
f.loading = "lazy";
|
||||
return f;
|
||||
}
|
||||
|
||||
function mountInline() {
|
||||
var nodes = document.querySelectorAll("[data-warmbly-form]");
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var el = nodes[i];
|
||||
if (el.getAttribute("data-warmbly-mounted")) continue;
|
||||
el.setAttribute("data-warmbly-mounted", "1");
|
||||
el.appendChild(makeFrame(el.getAttribute("data-warmbly-form")));
|
||||
}
|
||||
}
|
||||
|
||||
var overlay = null;
|
||||
function closePopup() {
|
||||
if (!overlay) return;
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
overlay.parentNode && overlay.parentNode.removeChild(overlay);
|
||||
overlay = null;
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === "Escape") closePopup();
|
||||
}
|
||||
|
||||
function openPopup(id) {
|
||||
closePopup();
|
||||
overlay = document.createElement("div");
|
||||
overlay.style.cssText =
|
||||
"position:fixed;inset:0;z-index:2147483000;background:rgba(15,23,42,.55);" +
|
||||
"display:flex;align-items:center;justify-content:center;padding:20px;overflow:auto";
|
||||
var box = document.createElement("div");
|
||||
box.style.cssText =
|
||||
"position:relative;width:100%;max-width:640px;max-height:92vh;overflow:auto;" +
|
||||
"border-radius:12px;background:transparent";
|
||||
var close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", "Close");
|
||||
close.innerHTML = "✕";
|
||||
close.style.cssText =
|
||||
"position:absolute;top:6px;right:6px;z-index:1;width:30px;height:30px;border:none;" +
|
||||
"border-radius:15px;background:rgba(15,23,42,.55);color:#fff;font-size:14px;cursor:pointer";
|
||||
close.onclick = closePopup;
|
||||
var frame = makeFrame(id);
|
||||
frame.loading = "eager";
|
||||
box.appendChild(close);
|
||||
box.appendChild(frame);
|
||||
overlay.appendChild(box);
|
||||
overlay.addEventListener("mousedown", function (e) {
|
||||
if (e.target === overlay) closePopup();
|
||||
});
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function wirePopups() {
|
||||
var nodes = document.querySelectorAll("[data-warmbly-popup]");
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var el = nodes[i];
|
||||
if (el.getAttribute("data-warmbly-mounted")) continue;
|
||||
el.setAttribute("data-warmbly-mounted", "1");
|
||||
el.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
openPopup(this.getAttribute("data-warmbly-popup"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", function (e) {
|
||||
if (base && e.origin !== base) return;
|
||||
var d = e.data;
|
||||
if (!d || typeof d !== "object") return;
|
||||
if (d.type === "warmbly:resize" && d.form) {
|
||||
var frames = document.querySelectorAll('iframe[data-warmbly-frame="' + d.form + '"]');
|
||||
for (var i = 0; i < frames.length; i++) {
|
||||
if (e.source && frames[i].contentWindow !== e.source) continue;
|
||||
frames[i].style.height = Math.max(120, d.height | 0) + "px";
|
||||
}
|
||||
}
|
||||
if (d.type === "warmbly:submitted") {
|
||||
if (overlay) setTimeout(closePopup, 2400);
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent("warmbly:submitted", { detail: { form: d.form } }));
|
||||
} catch (err) {}
|
||||
}
|
||||
});
|
||||
|
||||
window.WarmblyForms = { open: openPopup, close: closePopup, scan: function () { mountInline(); wirePopups(); } };
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () { mountInline(); wirePopups(); });
|
||||
} else {
|
||||
mountInline();
|
||||
wirePopups();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,50 @@
|
||||
package formserver
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The render token is the anti-scraping gate: the JSON API and submit
|
||||
// endpoints only answer requests carrying a token minted when the page shell
|
||||
// was served, so a script cannot harvest form definitions or spray submits
|
||||
// without loading the page first. The key derives from INTERNAL_API_TOKEN,
|
||||
// so replicas agree without any new configuration.
|
||||
|
||||
const renderTokenTTL = 12 * time.Hour
|
||||
|
||||
func renderKey(internalToken string) []byte {
|
||||
sum := sha256.Sum256([]byte(internalToken + ":forms-render"))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func renderMAC(key []byte, publicID string, expiry int64) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(publicID + "." + strconv.FormatInt(expiry, 10)))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
// mintRenderToken returns base64url(expiryUnix_be8 || HMAC(publicID.expiry)).
|
||||
func mintRenderToken(key []byte, publicID string, now time.Time) string {
|
||||
expiry := now.Add(renderTokenTTL).Unix()
|
||||
buf := make([]byte, 8, 8+sha256.Size)
|
||||
binary.BigEndian.PutUint64(buf, uint64(expiry))
|
||||
buf = append(buf, renderMAC(key, publicID, expiry)...)
|
||||
return base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func verifyRenderToken(key []byte, publicID, token string, now time.Time) bool {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil || len(raw) != 8+sha256.Size {
|
||||
return false
|
||||
}
|
||||
expiry := int64(binary.BigEndian.Uint64(raw[:8]))
|
||||
if now.Unix() > expiry {
|
||||
return false
|
||||
}
|
||||
return hmac.Equal(raw[8:], renderMAC(key, publicID, expiry))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package formwire is the wire contract between the backend's internal form
|
||||
// endpoints and the forms service (cmd/forms). Both processes marshal these
|
||||
// structs, so the two sides of the internal API cannot drift apart silently.
|
||||
package formwire
|
||||
|
||||
import "github.com/warmbly/warmbly/internal/models"
|
||||
|
||||
// PublicForm is everything the forms service needs to render one published
|
||||
// form: the definition plus the resolved captcha site key, and nothing the
|
||||
// visitor must not see (no org id, no counters, no owner).
|
||||
type PublicForm struct {
|
||||
PublicID string `json:"public_id"`
|
||||
Name string `json:"name"`
|
||||
Fields []models.FormField `json:"fields"`
|
||||
Design models.FormDesign `json:"design"`
|
||||
LogoURL string `json:"logo_url,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
BackgroundURL string `json:"background_url,omitempty"`
|
||||
AllowedDomains []string `json:"allowed_domains,omitempty"`
|
||||
CaptchaSiteKey string `json:"captcha_site_key,omitempty"`
|
||||
// Prefill and LinkToken appear only when a valid personalized ?t= ticket
|
||||
// accompanied the fetch: values for the mapped fields, and the token
|
||||
// echoed for submit/event attribution.
|
||||
Prefill map[string]string `json:"prefill,omitempty"`
|
||||
LinkToken string `json:"link_token,omitempty"`
|
||||
}
|
||||
|
||||
// SubmitRequest carries a visitor's answers plus the abuse signals only the
|
||||
// public-facing process can observe.
|
||||
type SubmitRequest struct {
|
||||
Answers map[string][]string `json:"answers"`
|
||||
RemoteIP string `json:"remote_ip"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
CaptchaToken string `json:"captcha_token,omitempty"`
|
||||
HoneypotFilled bool `json:"honeypot_filled,omitempty"`
|
||||
// RenderedAt is the unix second the page was served; bots submit near-instantly.
|
||||
RenderedAt int64 `json:"rendered_at,omitempty"`
|
||||
// LinkToken is the personalized ?t= ticket; VisitorKey ties the
|
||||
// submission to the visitor's funnel events.
|
||||
LinkToken string `json:"link_token,omitempty"`
|
||||
VisitorKey string `json:"visitor_key,omitempty"`
|
||||
}
|
||||
|
||||
// EventRequest is one funnel event forwarded by the forms service, plus the
|
||||
// request attributes the backend enriches from (RemoteIP becomes a country
|
||||
// and is discarded, UserAgent becomes a device class).
|
||||
type EventRequest struct {
|
||||
Type string `json:"type"`
|
||||
PageIndex int `json:"page_index"`
|
||||
PagesTotal int `json:"pages_total"`
|
||||
VisitorKey string `json:"visitor_key"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
LinkToken string `json:"link_token,omitempty"`
|
||||
RemoteIP string `json:"remote_ip"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
}
|
||||
|
||||
// SubmitResult is what the visitor sees after a successful submit.
|
||||
type SubmitResult struct {
|
||||
Message string `json:"message"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
}
|
||||
|
||||
// SubmitError is the 400 body for a rejected submission; Message is
|
||||
// visitor-facing and shown inline on the form.
|
||||
type SubmitError struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- The enum value and any 'form'-sourced rows stay; only the tables go.
|
||||
ALTER TABLE public.contacts DROP CONSTRAINT contacts_source_check;
|
||||
ALTER TABLE public.contacts
|
||||
ADD CONSTRAINT contacts_source_check
|
||||
CHECK (source IN ('unknown', 'manual', 'campaign', 'import', 'sheet_sync', 'api', 'ai_assistant', 'form'));
|
||||
|
||||
DROP TABLE IF EXISTS public.form_submissions;
|
||||
DROP TABLE IF EXISTS public.form_categories;
|
||||
DROP TABLE IF EXISTS public.forms;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Hosted lead-capture forms (issue #267). The field list and the design theme
|
||||
-- are read-then-execute blobs edited as a whole in the builder, so they live
|
||||
-- as jsonb validated at the app boundary; everything queried, counted or
|
||||
-- enforced in SQL is a typed column.
|
||||
CREATE TABLE public.forms (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
|
||||
created_by uuid REFERENCES public.users(id) ON DELETE SET NULL,
|
||||
public_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'draft',
|
||||
fields jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
design jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
success_message text NOT NULL DEFAULT 'Thanks! Your submission has been received.',
|
||||
redirect_url text NOT NULL DEFAULT '',
|
||||
campaign_id uuid REFERENCES public.campaigns(id) ON DELETE SET NULL,
|
||||
allowed_domains text[] NOT NULL DEFAULT '{}',
|
||||
captcha_enabled boolean NOT NULL DEFAULT false,
|
||||
views_count bigint NOT NULL DEFAULT 0,
|
||||
submissions_count bigint NOT NULL DEFAULT 0,
|
||||
last_submission_at timestamp with time zone,
|
||||
published_at timestamp with time zone,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT forms_status_check CHECK (status IN ('draft', 'published', 'archived')),
|
||||
CONSTRAINT forms_fields_check CHECK (jsonb_typeof(fields) = 'array'),
|
||||
CONSTRAINT forms_design_check CHECK (jsonb_typeof(design) = 'object')
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX forms_public_id_unique ON public.forms (public_id);
|
||||
CREATE INDEX idx_forms_org ON public.forms (organization_id);
|
||||
|
||||
COMMENT ON COLUMN public.forms.fields IS
|
||||
'Ordered field list, validated at the app boundary (models.FormField).';
|
||||
COMMENT ON COLUMN public.forms.design IS
|
||||
'Theme overrides, validated at the app boundary (models.FormDesign).';
|
||||
|
||||
-- Categories a submission files the contact under (the "list/tag" of #267).
|
||||
CREATE TABLE public.form_categories (
|
||||
form_id uuid NOT NULL REFERENCES public.forms(id) ON DELETE CASCADE,
|
||||
category_id uuid NOT NULL REFERENCES public.categories(id) ON DELETE CASCADE,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (form_id, category_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_form_categories_category ON public.form_categories (category_id);
|
||||
|
||||
CREATE TABLE public.form_submissions (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
form_id uuid NOT NULL REFERENCES public.forms(id) ON DELETE CASCADE,
|
||||
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
|
||||
contact_id uuid REFERENCES public.contacts(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
source_url text NOT NULL DEFAULT '',
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT form_submissions_data_check CHECK (jsonb_typeof(data) = 'object')
|
||||
);
|
||||
|
||||
CREATE INDEX idx_form_submissions_form ON public.form_submissions (form_id, created_at DESC);
|
||||
CREATE INDEX idx_form_submissions_org ON public.form_submissions (organization_id);
|
||||
CREATE INDEX idx_form_submissions_contact ON public.form_submissions (contact_id);
|
||||
|
||||
-- A submission is a first-touch origin and a timeline event of its own.
|
||||
ALTER TABLE public.contacts DROP CONSTRAINT contacts_source_check;
|
||||
ALTER TABLE public.contacts
|
||||
ADD CONSTRAINT contacts_source_check
|
||||
CHECK (source IN ('unknown', 'manual', 'campaign', 'import', 'sheet_sync', 'api', 'ai_assistant', 'form'));
|
||||
|
||||
ALTER TYPE public.activity_type ADD VALUE IF NOT EXISTS 'form_submitted';
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP TABLE IF EXISTS form_events;
|
||||
DROP TABLE IF EXISTS form_links;
|
||||
ALTER TABLE form_submissions
|
||||
DROP COLUMN IF EXISTS campaign_id;
|
||||
ALTER TABLE forms
|
||||
DROP COLUMN IF EXISTS logo_url,
|
||||
DROP COLUMN IF EXISTS cover_url;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Forms v2: brand assets, personalized links and funnel events. Design v2
|
||||
-- keys and page breaks are jsonb-additive and need no DDL.
|
||||
|
||||
ALTER TABLE forms
|
||||
ADD COLUMN logo_url text NOT NULL DEFAULT '',
|
||||
ADD COLUMN cover_url text NOT NULL DEFAULT '';
|
||||
|
||||
-- A submission arriving through a personalized link knows the campaign whose
|
||||
-- email carried that link.
|
||||
ALTER TABLE form_submissions
|
||||
ADD COLUMN campaign_id uuid REFERENCES campaigns(id) ON DELETE SET NULL;
|
||||
|
||||
-- Per-contact form URL tickets, mirroring tracked_links: the row id IS the
|
||||
-- token in ?t=, so no signing secret exists anywhere.
|
||||
CREATE TABLE form_links (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
form_id uuid NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
|
||||
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
contact_id uuid NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
campaign_id uuid REFERENCES campaigns(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (form_id, contact_id)
|
||||
);
|
||||
CREATE INDEX idx_form_links_org ON form_links (organization_id);
|
||||
CREATE INDEX idx_form_links_contact ON form_links (contact_id);
|
||||
|
||||
-- Funnel events, modeled on website_page_hits: enriched server-side from the
|
||||
-- request IP (country only), the IP itself is never stored.
|
||||
CREATE TABLE form_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
form_id uuid NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
|
||||
event_type text NOT NULL CHECK (event_type IN ('view','start','page','submit')),
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
visitor_key text NOT NULL DEFAULT '',
|
||||
page_index integer NOT NULL DEFAULT 0,
|
||||
pages_total integer NOT NULL DEFAULT 0,
|
||||
contact_id uuid REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
campaign_id uuid REFERENCES campaigns(id) ON DELETE SET NULL,
|
||||
referrer_domain text NOT NULL DEFAULT '',
|
||||
country_code text NOT NULL DEFAULT '',
|
||||
device text NOT NULL DEFAULT 'unknown'
|
||||
CHECK (device IN ('desktop','mobile','tablet','unknown'))
|
||||
);
|
||||
CREATE INDEX idx_form_events_form ON form_events (form_id, occurred_at DESC);
|
||||
CREATE INDEX idx_form_events_org ON form_events (organization_id, occurred_at DESC);
|
||||
CREATE INDEX idx_form_events_contact ON form_events (form_id, contact_id)
|
||||
WHERE contact_id IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE organizations
|
||||
DROP COLUMN IF EXISTS forms_domain,
|
||||
DROP COLUMN IF EXISTS forms_domain_verified,
|
||||
DROP COLUMN IF EXISTS forms_domain_verified_at;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Organization-level custom forms domain. A customer points a subdomain of
|
||||
-- the domain they send from at this install's forms host, so hosted form
|
||||
-- links carry their own name instead of the shared one. Only a verified
|
||||
-- domain is ever used to build a URL: an unresolved host would make every
|
||||
-- form link in an email dead, so it falls back to the shared host instead.
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN forms_domain text NOT NULL DEFAULT '',
|
||||
ADD COLUMN forms_domain_verified boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN forms_domain_verified_at timestamptz;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE forms DROP COLUMN IF EXISTS background_url;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- The page background image is a third brand asset alongside the logo and the
|
||||
-- cover. It gets its own column, like the other two, so org export carries the
|
||||
-- object with the row.
|
||||
ALTER TABLE forms ADD COLUMN background_url text NOT NULL DEFAULT '';
|
||||
@@ -71,6 +71,11 @@ const (
|
||||
// ['audit'] query on any event type containing "AUDIT".
|
||||
EventAuditCreated EventType = "AUDIT_CREATED"
|
||||
|
||||
// Form events (org-scoped). Submissions arrive with no dashboard actor,
|
||||
// so they cannot ride the audit spine; the web client invalidates the
|
||||
// ['forms'] queries on any event type containing "FORM".
|
||||
EventFormSubmission EventType = "FORM_SUBMISSION_CREATED"
|
||||
|
||||
// Automation events (org-scoped). The web client invalidates the
|
||||
// ['automations'] queries on any event type containing "AUTOMATION".
|
||||
EventAutomationCreated EventType = "AUTOMATION_CREATED"
|
||||
@@ -511,6 +516,41 @@ func (p *StreamingPublisher) PublishAuditCreated(ctx context.Context, orgID, act
|
||||
}
|
||||
}
|
||||
|
||||
// FormSubmissionEvent is the org-scoped payload for FORM_SUBMISSION_CREATED.
|
||||
// Ids only: the submission body stays behind the list endpoint's permission.
|
||||
type FormSubmissionEvent struct {
|
||||
BaseEvent
|
||||
OrgID string `json:"org_id"`
|
||||
FormID string `json:"form_id"`
|
||||
SubmissionID string `json:"submission_id,omitempty"`
|
||||
ContactID string `json:"contact_id,omitempty"`
|
||||
}
|
||||
|
||||
// PublishFormSubmission emits an org-scoped form submission signal. There is
|
||||
// no actor: public visitors submit, so UserID stays empty.
|
||||
func (p *StreamingPublisher) PublishFormSubmission(ctx context.Context, orgID, formID uuid.UUID, submissionID, contactID string) {
|
||||
if p == nil || p.client == nil || orgID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
event := &FormSubmissionEvent{
|
||||
BaseEvent: BaseEvent{
|
||||
EventType: EventFormSubmission,
|
||||
Timestamp: time.Now(),
|
||||
},
|
||||
OrgID: orgID.String(),
|
||||
FormID: formID.String(),
|
||||
SubmissionID: submissionID,
|
||||
ContactID: contactID,
|
||||
}
|
||||
attrs := map[string]string{
|
||||
"org_id": orgID.String(),
|
||||
"event_type": string(EventFormSubmission),
|
||||
}
|
||||
if err := p.client.Publish(ctx, TopicUserEvents, event, attrs); err != nil {
|
||||
// Best-effort: realtime is a nicety, not a requirement.
|
||||
}
|
||||
}
|
||||
|
||||
// AutomationEvent is an org-scoped automation lifecycle/run signal. The web
|
||||
// client invalidates the ['automations'] queries on any "AUTOMATION" event.
|
||||
type AutomationEvent struct {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// FormEventsRetentionJob prunes funnel events past the platform window; the
|
||||
// forms analytics ranges top out at 90 days, so the fixed window keeps double
|
||||
// coverage without a per-org setting.
|
||||
type FormEventsRetentionJob struct {
|
||||
repo repository.FormEventRepository
|
||||
}
|
||||
|
||||
func NewFormEventsRetentionJob(repo repository.FormEventRepository) *FormEventsRetentionJob {
|
||||
return &FormEventsRetentionJob{repo: repo}
|
||||
}
|
||||
|
||||
func (j *FormEventsRetentionJob) Run(ctx context.Context) error {
|
||||
if j.repo == nil {
|
||||
return nil
|
||||
}
|
||||
before := time.Now().AddDate(0, 0, -config.FormEventsRetentionDays)
|
||||
if _, xerr := j.repo.PruneBefore(ctx, before); xerr != nil {
|
||||
return xerr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start runs the job once on boot and then on the interval until ctx ends.
|
||||
func (j *FormEventsRetentionJob) Start(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
_ = j.Run(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
_ = j.Run(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/pkg/trackdns"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
// FormsDomainSweep re-resolves every organization's custom forms domain on an
|
||||
// interval, for the same reason the mailbox tracking-domain sweep exists: DNS
|
||||
// is not a one-time fact. A record that finishes propagating after the
|
||||
// customer pressed save starts being used without them coming back, and one
|
||||
// that later stops pointing here stops being used instead of quietly serving
|
||||
// form links on a host that no longer answers.
|
||||
type FormsDomainSweep struct {
|
||||
orgs repository.OrganizationRepository
|
||||
}
|
||||
|
||||
func NewFormsDomainSweep(orgs repository.OrganizationRepository) *FormsDomainSweep {
|
||||
return &FormsDomainSweep{orgs: orgs}
|
||||
}
|
||||
|
||||
func (j *FormsDomainSweep) Run(ctx context.Context) error {
|
||||
if j.orgs == nil {
|
||||
return nil
|
||||
}
|
||||
target := config.FormsHostname()
|
||||
if target == "" {
|
||||
// Nothing to point a record at; every lookup would fail the same way.
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := j.orgs.ListFormsDomains(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
res := trackdns.Verify(ctx, row.Domain, target)
|
||||
// A resolver failure is transient: never let it un-verify a domain
|
||||
// that was working, or one DNS blip drops every customer back to the
|
||||
// shared host.
|
||||
if res.Code == trackdns.CodeLookupError {
|
||||
continue
|
||||
}
|
||||
if res.Verified == row.Verified {
|
||||
continue
|
||||
}
|
||||
var at *time.Time
|
||||
if res.Verified {
|
||||
now := time.Now().UTC()
|
||||
at = &now
|
||||
}
|
||||
if err := j.orgs.SetFormsDomainVerified(ctx, row.OrganizationID, res.Verified, at); err != nil {
|
||||
log.Warn().Err(err).Str("organization_id", row.OrganizationID.String()).Msg("forms domain sweep: persist failed")
|
||||
continue
|
||||
}
|
||||
log.Info().
|
||||
Str("organization_id", row.OrganizationID.String()).
|
||||
Str("domain", row.Domain).
|
||||
Bool("verified", res.Verified).
|
||||
Str("code", res.Code).
|
||||
Msg("forms domain verification changed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start runs the sweep once on boot and then on the interval until ctx ends.
|
||||
func (j *FormsDomainSweep) Start(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
_ = j.Run(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
_ = j.Run(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ const (
|
||||
AuditEntityTag AuditEntityType = "tag"
|
||||
AuditEntityCategory AuditEntityType = "category"
|
||||
AuditEntitySegment AuditEntityType = "segment"
|
||||
AuditEntityForm AuditEntityType = "form"
|
||||
AuditEntitySubscription AuditEntityType = "subscription"
|
||||
AuditEntitySettings AuditEntityType = "settings"
|
||||
|
||||
|
||||
@@ -402,6 +402,7 @@ const (
|
||||
TimelineCampaignRemoved ContactTimelineEventType = "campaign_removed"
|
||||
TimelineCategoryAdded ContactTimelineEventType = "category_added"
|
||||
TimelineCategoryRemoved ContactTimelineEventType = "category_removed"
|
||||
TimelineFormSubmitted ContactTimelineEventType = "form_submitted"
|
||||
|
||||
// A page view from the website tracking snippet, tied to the contact
|
||||
// through an email-link ticket. Detail rides in PageHit.
|
||||
@@ -450,6 +451,10 @@ type ContactTimelineEvent struct {
|
||||
CategoryTitle *string `json:"category_title,omitempty"`
|
||||
SourceDetail *string `json:"source_detail,omitempty"`
|
||||
|
||||
// Form submission (form_submitted): which hosted form was filled in.
|
||||
FormID *uuid.UUID `json:"form_id,omitempty"`
|
||||
FormName *string `json:"form_name,omitempty"`
|
||||
|
||||
// Website page view (page_hit): URL, referrer, device, UTM, location.
|
||||
PageHit *WebsitePageHit `json:"page_hit,omitempty"`
|
||||
|
||||
@@ -534,13 +539,14 @@ const (
|
||||
ContactSourceSheetSync ContactSource = "sheet_sync"
|
||||
ContactSourceAPI ContactSource = "api"
|
||||
ContactSourceAIAssistant ContactSource = "ai_assistant"
|
||||
ContactSourceForm ContactSource = "form"
|
||||
)
|
||||
|
||||
// Valid reports whether the value is one the database accepts.
|
||||
func (s ContactSource) Valid() bool {
|
||||
switch s {
|
||||
case ContactSourceUnknown, ContactSourceManual, ContactSourceCampaign, ContactSourceImport,
|
||||
ContactSourceSheetSync, ContactSourceAPI, ContactSourceAIAssistant:
|
||||
ContactSourceSheetSync, ContactSourceAPI, ContactSourceAIAssistant, ContactSourceForm:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -57,6 +57,7 @@ const (
|
||||
ActivityTaskCompleted ActivityType = "task_completed"
|
||||
ActivityContactCreated ActivityType = "contact_created"
|
||||
ActivityContactUpdated ActivityType = "contact_updated"
|
||||
ActivityFormSubmitted ActivityType = "form_submitted"
|
||||
ActivityCampaignAdded ActivityType = "campaign_added"
|
||||
ActivityCampaignRemoved ActivityType = "campaign_removed"
|
||||
ActivityCategoryAdded ActivityType = "category_added"
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
)
|
||||
|
||||
// Form is a hosted lead-capture form: an ordered field list plus a design
|
||||
// theme, published at a public URL and embeddable on any website. A
|
||||
// submission creates or updates a contact, files it under the form's
|
||||
// categories and optionally adds it to a campaign.
|
||||
type Form struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
|
||||
// PublicID is the unguessable token in the public URL and embed codes.
|
||||
PublicID string `json:"public_id"`
|
||||
Name string `json:"name"`
|
||||
Status FormStatus `json:"status"`
|
||||
Fields []FormField `json:"fields"`
|
||||
Design FormDesign `json:"design"`
|
||||
SuccessMessage string `json:"success_message"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
|
||||
CategoryIDs []uuid.UUID `json:"category_ids"`
|
||||
AllowedDomains []string `json:"allowed_domains"`
|
||||
CaptchaEnabled bool `json:"captcha_enabled"`
|
||||
// LogoURL, CoverURL and BackgroundURL are uploaded brand assets (public
|
||||
// object URLs).
|
||||
LogoURL string `json:"logo_url"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
BackgroundURL string `json:"background_url"`
|
||||
|
||||
ViewsCount int64 `json:"views_count"`
|
||||
SubmissionsCount int64 `json:"submissions_count"`
|
||||
LastSubmissionAt *time.Time `json:"last_submission_at,omitempty"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
|
||||
// Event-derived aggregates, populated by List only (nil Trend elsewhere).
|
||||
StartsCount int64 `json:"starts_count"`
|
||||
IdentifiedCount int64 `json:"identified_count"`
|
||||
Trend []int64 `json:"trend,omitempty"`
|
||||
|
||||
// ShareURL is the hosted page URL, derived from config on read.
|
||||
ShareURL string `json:"share_url,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// FormStatus is the lifecycle of a form. Only published forms render and
|
||||
// accept submissions; archiving keeps the data but takes the page offline.
|
||||
type FormStatus string
|
||||
|
||||
const (
|
||||
FormStatusDraft FormStatus = "draft"
|
||||
FormStatusPublished FormStatus = "published"
|
||||
FormStatusArchived FormStatus = "archived"
|
||||
)
|
||||
|
||||
// Valid reports whether the value is one the database accepts.
|
||||
func (s FormStatus) Valid() bool {
|
||||
switch s {
|
||||
case FormStatusDraft, FormStatusPublished, FormStatusArchived:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FormFieldType is one kind of builder block. Input types collect a value;
|
||||
// layout types (heading, paragraph, divider) only render.
|
||||
type FormFieldType string
|
||||
|
||||
const (
|
||||
FormFieldText FormFieldType = "text"
|
||||
FormFieldEmail FormFieldType = "email"
|
||||
FormFieldPhone FormFieldType = "phone"
|
||||
FormFieldTextarea FormFieldType = "textarea"
|
||||
FormFieldNumber FormFieldType = "number"
|
||||
FormFieldSelect FormFieldType = "select"
|
||||
FormFieldRadio FormFieldType = "radio"
|
||||
FormFieldCheckboxes FormFieldType = "checkboxes"
|
||||
FormFieldCheckbox FormFieldType = "checkbox"
|
||||
FormFieldDate FormFieldType = "date"
|
||||
FormFieldHidden FormFieldType = "hidden"
|
||||
FormFieldHeading FormFieldType = "heading"
|
||||
FormFieldParagraph FormFieldType = "paragraph"
|
||||
FormFieldDivider FormFieldType = "divider"
|
||||
// FormFieldPageBreak splits the form into pages; its label is the page title.
|
||||
FormFieldPageBreak FormFieldType = "page_break"
|
||||
)
|
||||
|
||||
// Valid reports whether the type is a known block.
|
||||
func (t FormFieldType) Valid() bool {
|
||||
switch t {
|
||||
case FormFieldText, FormFieldEmail, FormFieldPhone, FormFieldTextarea, FormFieldNumber,
|
||||
FormFieldSelect, FormFieldRadio, FormFieldCheckboxes, FormFieldCheckbox, FormFieldDate,
|
||||
FormFieldHidden, FormFieldHeading, FormFieldParagraph, FormFieldDivider, FormFieldPageBreak:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsInput reports whether the block collects a value on submit.
|
||||
func (t FormFieldType) IsInput() bool {
|
||||
switch t {
|
||||
case FormFieldHeading, FormFieldParagraph, FormFieldDivider, FormFieldPageBreak:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HasOptions reports whether the block needs an options list.
|
||||
func (t FormFieldType) HasOptions() bool {
|
||||
return t == FormFieldSelect || t == FormFieldRadio || t == FormFieldCheckboxes
|
||||
}
|
||||
|
||||
// FormField is one block on the form. ID is a builder-generated slug, stable
|
||||
// across edits; submissions key their answers by it.
|
||||
type FormField struct {
|
||||
ID string `json:"id"`
|
||||
Type FormFieldType `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
HelpText string `json:"help_text,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
// MapTo names the contact column the answer fills (first_name, last_name,
|
||||
// email, company, phone). Empty means the answer lands in the contact's
|
||||
// custom fields under the field label.
|
||||
MapTo string `json:"map_to,omitempty"`
|
||||
// Value is the constant a hidden field submits, or the body text of a
|
||||
// paragraph block.
|
||||
Value string `json:"value,omitempty"`
|
||||
// Width is full or half; two half fields share a row.
|
||||
Width string `json:"width,omitempty"`
|
||||
Rows int `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
// FormDesign is the theme the builder's Design panel edits. Unset values fall
|
||||
// back to the defaults in NormalizeFormDesign, so an older row still renders.
|
||||
type FormDesign struct {
|
||||
FontFamily string `json:"font_family,omitempty"`
|
||||
PageBackground string `json:"page_background,omitempty"`
|
||||
FormBackground string `json:"form_background,omitempty"`
|
||||
TextColor string `json:"text_color,omitempty"`
|
||||
LabelColor string `json:"label_color,omitempty"`
|
||||
InputBackground string `json:"input_background,omitempty"`
|
||||
InputBorderColor string `json:"input_border_color,omitempty"`
|
||||
InputTextColor string `json:"input_text_color,omitempty"`
|
||||
PlaceholderColor string `json:"placeholder_color,omitempty"`
|
||||
AccentColor string `json:"accent_color,omitempty"`
|
||||
ButtonBackground string `json:"button_background,omitempty"`
|
||||
ButtonTextColor string `json:"button_text_color,omitempty"`
|
||||
ButtonText string `json:"button_text,omitempty"`
|
||||
ButtonSize string `json:"button_size,omitempty"`
|
||||
ButtonFullWidth bool `json:"button_full_width,omitempty"`
|
||||
BorderRadius *int `json:"border_radius,omitempty"`
|
||||
MaxWidth *int `json:"max_width,omitempty"`
|
||||
Spacing string `json:"spacing,omitempty"`
|
||||
Shadow *bool `json:"shadow,omitempty"`
|
||||
// Theme records which preset seeded the current colors; renderers never
|
||||
// read it, so new presets need no backend release.
|
||||
Theme string `json:"theme,omitempty"`
|
||||
// Layout is card (centered box), wide (no box) or split (cover panel).
|
||||
Layout string `json:"layout,omitempty"`
|
||||
// Mode is classic (pages of fields) or focus (one question per screen).
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// PageBackgroundEnd turns the page background into a vertical gradient.
|
||||
PageBackgroundEnd string `json:"page_background_end,omitempty"`
|
||||
Align string `json:"align,omitempty"`
|
||||
ShowProgress *bool `json:"show_progress,omitempty"`
|
||||
|
||||
// BackgroundSize and BackgroundOverlay style the uploaded background_url:
|
||||
// the overlay (0-100) veils the image with the page color so text on top
|
||||
// stays legible.
|
||||
BackgroundSize string `json:"background_size,omitempty"`
|
||||
BackgroundOverlay *int `json:"background_overlay,omitempty"`
|
||||
|
||||
// A bar carrying the logo and a title: whether it spans the page or sits
|
||||
// with the form, how its contents align, and whether it sticks on scroll.
|
||||
HeaderEnabled *bool `json:"header_enabled,omitempty"`
|
||||
HeaderTitle string `json:"header_title,omitempty"`
|
||||
HeaderBackground string `json:"header_background,omitempty"`
|
||||
HeaderPlacement string `json:"header_placement,omitempty"`
|
||||
HeaderAlign string `json:"header_align,omitempty"`
|
||||
HeaderSticky *bool `json:"header_sticky,omitempty"`
|
||||
HeaderShowLogo *bool `json:"header_show_logo,omitempty"`
|
||||
|
||||
// Copy over the split layout's cover panel.
|
||||
CoverTitle string `json:"cover_title,omitempty"`
|
||||
CoverSubtitle string `json:"cover_subtitle,omitempty"`
|
||||
|
||||
// How the uploaded logo renders: its height, and whether it sits on the
|
||||
// form surface or above the card on the page background.
|
||||
LogoSize string `json:"logo_size,omitempty"`
|
||||
LogoPosition string `json:"logo_position,omitempty"`
|
||||
}
|
||||
|
||||
// FormWrite is the PATCH payload; every field is optional.
|
||||
type FormWrite struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Status *FormStatus `json:"status,omitempty"`
|
||||
Fields *[]FormField `json:"fields,omitempty"`
|
||||
Design *FormDesign `json:"design,omitempty"`
|
||||
SuccessMessage *string `json:"success_message,omitempty"`
|
||||
RedirectURL *string `json:"redirect_url,omitempty"`
|
||||
CampaignID NullableUUID `json:"campaign_id"`
|
||||
CategoryIDs *[]uuid.UUID `json:"category_ids,omitempty"`
|
||||
AllowedDomains *[]string `json:"allowed_domains,omitempty"`
|
||||
CaptchaEnabled *bool `json:"captcha_enabled,omitempty"`
|
||||
}
|
||||
|
||||
// FormSubmission is one public submit, kept verbatim. Data is keyed by field
|
||||
// id; checkbox groups store a string slice, everything else a string.
|
||||
type FormSubmission struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
FormID uuid.UUID `json:"form_id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
ContactID *uuid.UUID `json:"contact_id,omitempty"`
|
||||
// CampaignID is the campaign whose email carried the personalized link
|
||||
// the visitor arrived through, when there was one.
|
||||
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
|
||||
Data map[string]any `json:"data"`
|
||||
SourceURL string `json:"source_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Contact and campaign summaries for the submissions table, populated by
|
||||
// reads.
|
||||
ContactEmail string `json:"contact_email,omitempty"`
|
||||
ContactName string `json:"contact_name,omitempty"`
|
||||
CampaignName string `json:"campaign_name,omitempty"`
|
||||
}
|
||||
|
||||
// Form limits.
|
||||
const (
|
||||
FormMaxNameLen = 120
|
||||
FormsPerOrgMax = 100
|
||||
FormMaxFields = 60
|
||||
FormMaxLabelLen = 200
|
||||
FormMaxTextLen = 2000
|
||||
FormMaxOptions = 50
|
||||
FormMaxOptionLen = 100
|
||||
FormMaxDomains = 20
|
||||
FormMaxAnswerLen = 5000
|
||||
FormMaxCategories = 20
|
||||
FormMinBorderRadius = 0
|
||||
FormMaxBorderRadius = 24
|
||||
FormMinWidth = 320
|
||||
FormMaxWidth = 960
|
||||
FormDefaultWidth = 560
|
||||
FormDefaultSuccessMsg = "Thanks! Your submission has been received."
|
||||
)
|
||||
|
||||
var (
|
||||
formFieldIDRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`)
|
||||
formHexRE = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
|
||||
formThemeRE = regexp.MustCompile(`^[a-z0-9-]{0,40}$`)
|
||||
)
|
||||
|
||||
// FormContactColumns is every contact column a field may map to.
|
||||
var FormContactColumns = map[string]bool{
|
||||
"first_name": true,
|
||||
"last_name": true,
|
||||
"email": true,
|
||||
"company": true,
|
||||
"phone": true,
|
||||
}
|
||||
|
||||
// ValidateFormName trims and bounds the internal name.
|
||||
func ValidateFormName(name string) (string, *errx.Error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", errx.New(errx.BadRequest, "form name is required")
|
||||
}
|
||||
if len(name) > FormMaxNameLen {
|
||||
return "", errx.New(errx.BadRequest, fmt.Sprintf("form name must be at most %d characters", FormMaxNameLen))
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// ValidateFormFields checks and normalizes the field list in place.
|
||||
func ValidateFormFields(fields []FormField) *errx.Error {
|
||||
if len(fields) > FormMaxFields {
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("at most %d fields per form", FormMaxFields))
|
||||
}
|
||||
seenID := make(map[string]bool, len(fields))
|
||||
seenMap := make(map[string]bool, len(fields))
|
||||
for i := range fields {
|
||||
f := &fields[i]
|
||||
f.ID = strings.TrimSpace(f.ID)
|
||||
f.Label = strings.TrimSpace(f.Label)
|
||||
f.Placeholder = strings.TrimSpace(f.Placeholder)
|
||||
f.HelpText = strings.TrimSpace(f.HelpText)
|
||||
f.MapTo = strings.TrimSpace(f.MapTo)
|
||||
f.Value = strings.TrimSpace(f.Value)
|
||||
at := fmt.Sprintf("field %d", i+1)
|
||||
if !formFieldIDRE.MatchString(f.ID) {
|
||||
return errx.New(errx.BadRequest, at+": id must be a short lowercase slug")
|
||||
}
|
||||
if seenID[f.ID] {
|
||||
return errx.New(errx.BadRequest, at+": duplicate id "+f.ID)
|
||||
}
|
||||
seenID[f.ID] = true
|
||||
if !f.Type.Valid() {
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("%s: unknown type %q", at, f.Type))
|
||||
}
|
||||
if len(f.Label) > FormMaxLabelLen {
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("%s: label must be at most %d characters", at, FormMaxLabelLen))
|
||||
}
|
||||
if len(f.Placeholder) > FormMaxLabelLen || len(f.HelpText) > FormMaxLabelLen {
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("%s: placeholder and help text must be at most %d characters", at, FormMaxLabelLen))
|
||||
}
|
||||
if len(f.Value) > FormMaxTextLen {
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("%s: text must be at most %d characters", at, FormMaxTextLen))
|
||||
}
|
||||
if f.Type.IsInput() && f.Type != FormFieldHidden && f.Label == "" {
|
||||
return errx.New(errx.BadRequest, at+": a label is required")
|
||||
}
|
||||
if f.Type.HasOptions() {
|
||||
if len(f.Options) == 0 {
|
||||
return errx.New(errx.BadRequest, at+": at least one option is required")
|
||||
}
|
||||
if len(f.Options) > FormMaxOptions {
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("%s: at most %d options", at, FormMaxOptions))
|
||||
}
|
||||
seenOpt := make(map[string]bool, len(f.Options))
|
||||
for j, opt := range f.Options {
|
||||
opt = strings.TrimSpace(opt)
|
||||
f.Options[j] = opt
|
||||
if opt == "" || len(opt) > FormMaxOptionLen {
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("%s: options must be 1 to %d characters", at, FormMaxOptionLen))
|
||||
}
|
||||
if seenOpt[opt] {
|
||||
return errx.New(errx.BadRequest, at+": duplicate option "+opt)
|
||||
}
|
||||
seenOpt[opt] = true
|
||||
}
|
||||
} else {
|
||||
f.Options = nil
|
||||
}
|
||||
if f.MapTo != "" {
|
||||
if !f.Type.IsInput() {
|
||||
return errx.New(errx.BadRequest, at+": only input fields can map to a contact column")
|
||||
}
|
||||
if !FormContactColumns[f.MapTo] {
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("%s: unknown contact column %q", at, f.MapTo))
|
||||
}
|
||||
if seenMap[f.MapTo] {
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("%s: another field already maps to %s", at, f.MapTo))
|
||||
}
|
||||
seenMap[f.MapTo] = true
|
||||
}
|
||||
if f.Type == FormFieldEmail {
|
||||
f.MapTo = "email"
|
||||
}
|
||||
if f.Width != "" && f.Width != "full" && f.Width != "half" {
|
||||
return errx.New(errx.BadRequest, at+": width must be full or half")
|
||||
}
|
||||
if f.Rows < 0 || f.Rows > 20 {
|
||||
return errx.New(errx.BadRequest, at+": rows must be between 0 and 20")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormEmailField returns the field that fills the contact email, if any.
|
||||
func FormEmailField(fields []FormField) *FormField {
|
||||
for i := range fields {
|
||||
if fields[i].MapTo == "email" || fields[i].Type == FormFieldEmail {
|
||||
return &fields[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateFormDesign checks colors and enums; NormalizeFormDesign clamps the
|
||||
// numeric knobs and fills defaults.
|
||||
func ValidateFormDesign(d *FormDesign) *errx.Error {
|
||||
for _, c := range []string{
|
||||
d.PageBackground, d.FormBackground, d.TextColor, d.LabelColor, d.InputBackground,
|
||||
d.InputBorderColor, d.InputTextColor, d.PlaceholderColor, d.AccentColor,
|
||||
d.ButtonBackground, d.ButtonTextColor, d.PageBackgroundEnd, d.HeaderBackground,
|
||||
} {
|
||||
if c != "" && !formHexRE.MatchString(c) && c != "transparent" {
|
||||
return errx.New(errx.BadRequest, "design colors must be #rrggbb values")
|
||||
}
|
||||
}
|
||||
// The list must match FONT_CATALOG in the mirrored designCore.ts.
|
||||
switch d.FontFamily {
|
||||
case "", "system", "inter", "serif", "mono", "manrope", "sora", "fraunces", "space-grotesk":
|
||||
default:
|
||||
return errx.New(errx.BadRequest, "font_family is not a supported font")
|
||||
}
|
||||
switch d.Layout {
|
||||
case "", "card", "wide", "split":
|
||||
default:
|
||||
return errx.New(errx.BadRequest, "layout must be card, wide or split")
|
||||
}
|
||||
switch d.Mode {
|
||||
case "", "classic", "focus":
|
||||
default:
|
||||
return errx.New(errx.BadRequest, "mode must be classic or focus")
|
||||
}
|
||||
switch d.Align {
|
||||
case "", "left", "center":
|
||||
default:
|
||||
return errx.New(errx.BadRequest, "align must be left or center")
|
||||
}
|
||||
switch d.HeaderPlacement {
|
||||
case "", "page", "inline":
|
||||
default:
|
||||
return errx.New(errx.BadRequest, "header_placement must be page or inline")
|
||||
}
|
||||
switch d.HeaderAlign {
|
||||
case "", "left", "center", "between":
|
||||
default:
|
||||
return errx.New(errx.BadRequest, "header_align must be left, center or between")
|
||||
}
|
||||
switch d.LogoSize {
|
||||
case "", "sm", "md", "lg":
|
||||
default:
|
||||
return errx.New(errx.BadRequest, "logo_size must be sm, md or lg")
|
||||
}
|
||||
switch d.LogoPosition {
|
||||
case "", "card", "page":
|
||||
default:
|
||||
return errx.New(errx.BadRequest, "logo_position must be card or page")
|
||||
}
|
||||
switch d.BackgroundSize {
|
||||
case "", "cover", "contain", "tile":
|
||||
default:
|
||||
return errx.New(errx.BadRequest, "background_size must be cover, contain or tile")
|
||||
}
|
||||
if d.BackgroundOverlay != nil && (*d.BackgroundOverlay < 0 || *d.BackgroundOverlay > 100) {
|
||||
return errx.New(errx.BadRequest, "background_overlay must be between 0 and 100")
|
||||
}
|
||||
for _, t := range []string{d.HeaderTitle, d.CoverTitle} {
|
||||
if len(t) > FormMaxLabelLen {
|
||||
return errx.New(errx.BadRequest, "title is too long")
|
||||
}
|
||||
}
|
||||
if len(d.CoverSubtitle) > FormMaxTextLen {
|
||||
return errx.New(errx.BadRequest, "cover subtitle is too long")
|
||||
}
|
||||
if !formThemeRE.MatchString(d.Theme) {
|
||||
return errx.New(errx.BadRequest, "theme must be a short lowercase slug")
|
||||
}
|
||||
switch d.ButtonSize {
|
||||
case "", "sm", "md", "lg":
|
||||
default:
|
||||
return errx.New(errx.BadRequest, "button_size must be sm, md or lg")
|
||||
}
|
||||
switch d.Spacing {
|
||||
case "", "compact", "normal", "relaxed":
|
||||
default:
|
||||
return errx.New(errx.BadRequest, "spacing must be compact, normal or relaxed")
|
||||
}
|
||||
if len(d.ButtonText) > FormMaxLabelLen {
|
||||
return errx.New(errx.BadRequest, "button text is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeFormDesign clamps numbers into range. String defaults are applied
|
||||
// at render time so a stored row stays sparse.
|
||||
func NormalizeFormDesign(d *FormDesign) {
|
||||
if d.BorderRadius != nil {
|
||||
v := clampInt(*d.BorderRadius, FormMinBorderRadius, FormMaxBorderRadius)
|
||||
d.BorderRadius = &v
|
||||
}
|
||||
if d.MaxWidth != nil {
|
||||
v := clampInt(*d.MaxWidth, FormMinWidth, FormMaxWidth)
|
||||
d.MaxWidth = &v
|
||||
}
|
||||
d.ButtonText = strings.TrimSpace(d.ButtonText)
|
||||
}
|
||||
|
||||
func clampInt(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ValidateFormRedirectURL accepts an empty value or an absolute http(s) URL.
|
||||
func ValidateFormRedirectURL(raw string) (string, *errx.Error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return "", errx.New(errx.BadRequest, "redirect URL must be an absolute http(s) URL")
|
||||
}
|
||||
if len(raw) > 2048 {
|
||||
return "", errx.New(errx.BadRequest, "redirect URL is too long")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// ValidateFormDomains normalizes the embed allowlist to bare lowercase hosts.
|
||||
// A host admits its subdomains, mirroring website tracking.
|
||||
func ValidateFormDomains(domains []string) ([]string, *errx.Error) {
|
||||
if len(domains) > FormMaxDomains {
|
||||
return nil, errx.New(errx.BadRequest, fmt.Sprintf("at most %d allowed domains", FormMaxDomains))
|
||||
}
|
||||
out := make([]string, 0, len(domains))
|
||||
seen := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
h := strings.ToLower(strings.TrimSpace(d))
|
||||
h = strings.TrimPrefix(h, "https://")
|
||||
h = strings.TrimPrefix(h, "http://")
|
||||
if i := strings.IndexAny(h, "/?#"); i >= 0 {
|
||||
h = h[:i]
|
||||
}
|
||||
h = strings.TrimSuffix(h, ".")
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
if len(h) > 253 || strings.ContainsAny(h, " \t@") {
|
||||
return nil, errx.New(errx.BadRequest, fmt.Sprintf("%q is not a valid domain", d))
|
||||
}
|
||||
if !seen[h] {
|
||||
seen[h] = true
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FormDomainAllowed reports whether host is covered by the allowlist. An
|
||||
// empty list allows everything.
|
||||
func FormDomainAllowed(allowed []string, host string) bool {
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
if h, _, ok := strings.Cut(host, ":"); ok {
|
||||
host = h
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if host == a || strings.HasSuffix(host, "."+a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FormLinkMarkerRE matches the {{form_link:<public_id>}} marker the campaign
|
||||
// editor inserts; the send pipeline resolves it per recipient. Keep in sync
|
||||
// with FORM_LINK_RE in web/src/lib/templateVars.ts.
|
||||
var FormLinkMarkerRE = regexp.MustCompile(`\{\{\s*form_link:([a-z0-9]{1,64})\s*\}\}`)
|
||||
|
||||
// FormLink is a per-contact form URL ticket: the row id is the opaque token
|
||||
// in ?t=, so possession of the link identifies the contact (same posture as
|
||||
// tracked_links click tickets).
|
||||
type FormLink struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
FormID uuid.UUID `json:"form_id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
ContactID uuid.UUID `json:"contact_id"`
|
||||
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// FormEventType is one step of the visitor funnel.
|
||||
type FormEventType string
|
||||
|
||||
const (
|
||||
FormEventView FormEventType = "view"
|
||||
FormEventStart FormEventType = "start"
|
||||
FormEventPage FormEventType = "page"
|
||||
FormEventSubmit FormEventType = "submit"
|
||||
)
|
||||
|
||||
// Valid reports whether the value is one the database accepts.
|
||||
func (t FormEventType) Valid() bool {
|
||||
switch t {
|
||||
case FormEventView, FormEventStart, FormEventPage, FormEventSubmit:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FormEvent is one funnel event, enriched server-side. The visitor IP is
|
||||
// used for the country lookup and then discarded, never stored.
|
||||
type FormEvent struct {
|
||||
OrganizationID uuid.UUID
|
||||
FormID uuid.UUID
|
||||
Type FormEventType
|
||||
VisitorKey string
|
||||
PageIndex int
|
||||
PagesTotal int
|
||||
ContactID *uuid.UUID
|
||||
CampaignID *uuid.UUID
|
||||
ReferrerDomain string
|
||||
CountryCode string
|
||||
Device string
|
||||
}
|
||||
|
||||
// FormStats is the analytics payload for one form over a date range.
|
||||
type FormStats struct {
|
||||
Totals FormStatsTotals `json:"totals"`
|
||||
Daily []FormStatsDay `json:"daily"`
|
||||
Pages []FormFunnelPage `json:"pages"`
|
||||
Sources []FormStatsBucket `json:"sources"`
|
||||
Countries []FormStatsBucket `json:"countries"`
|
||||
Devices []FormStatsBucket `json:"devices"`
|
||||
Campaigns []FormStatsBucket `json:"campaigns"`
|
||||
Identified []FormIdentifiedVisitor `json:"identified"`
|
||||
}
|
||||
|
||||
type FormStatsTotals struct {
|
||||
Views int64 `json:"views"`
|
||||
Starts int64 `json:"starts"`
|
||||
Submissions int64 `json:"submissions"`
|
||||
CompletionRate float64 `json:"completion_rate"`
|
||||
IdentifiedVisitors int64 `json:"identified_visitors"`
|
||||
}
|
||||
|
||||
type FormStatsDay struct {
|
||||
Date string `json:"date"`
|
||||
Views int64 `json:"views"`
|
||||
Starts int64 `json:"starts"`
|
||||
Submissions int64 `json:"submissions"`
|
||||
}
|
||||
|
||||
// FormFunnelPage is one row of the page funnel: how many visitors reached
|
||||
// the page, and how many of those went on to submit.
|
||||
type FormFunnelPage struct {
|
||||
PageIndex int `json:"page_index"`
|
||||
Title string `json:"title"`
|
||||
Reached int64 `json:"reached"`
|
||||
CompletedFrom int64 `json:"completed_from"`
|
||||
}
|
||||
|
||||
type FormStatsBucket struct {
|
||||
Key string `json:"key"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type FormIdentifiedVisitor struct {
|
||||
ContactID uuid.UUID `json:"contact_id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
FurthestPage int `json:"furthest_page"`
|
||||
Completed bool `json:"completed"`
|
||||
// Campaign names the campaign whose email brought this contact here.
|
||||
Campaign string `json:"campaign,omitempty"`
|
||||
}
|
||||
|
||||
// CampaignFormStats is one form's performance inside a single campaign: how
|
||||
// many recipients were given a personalized link, and what they did with it.
|
||||
type CampaignFormStats struct {
|
||||
FormID uuid.UUID `json:"form_id"`
|
||||
FormName string `json:"form_name"`
|
||||
PublicID string `json:"public_id"`
|
||||
Status string `json:"status"`
|
||||
LinksSent int64 `json:"links_sent"`
|
||||
Viewers int64 `json:"viewers"`
|
||||
Starters int64 `json:"starters"`
|
||||
Submissions int64 `json:"submissions"`
|
||||
ShareURL string `json:"share_url,omitempty"`
|
||||
}
|
||||
|
||||
// FormsDomainStatus is the state of an organization's custom forms domain,
|
||||
// shaped like TrackingDomainStatus so the dashboard renders both the same
|
||||
// way. Only a verified domain is used to build form URLs: an unresolved host
|
||||
// would make every form link in a sent email dead, so it falls back to the
|
||||
// install's shared forms host instead.
|
||||
type FormsDomainStatus struct {
|
||||
FormsDomain string `json:"forms_domain"`
|
||||
FormsDomainVerified bool `json:"forms_domain_verified"`
|
||||
FormsDomainVerifiedAt *time.Time `json:"forms_domain_verified_at"`
|
||||
|
||||
// CNAMETarget is the value to put in the CNAME: this install's forms host
|
||||
// (FORMS_DOMAIN). Empty means the install has none, so nothing can verify.
|
||||
CNAMETarget string `json:"cname_target"`
|
||||
|
||||
// Status is stable and machine-readable, straight from trackdns: verified,
|
||||
// unset, no_target, not_found, wrong_target or lookup_error.
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
// Observed is what DNS actually returned, so a customer can spot their own
|
||||
// typo by comparing it with what they typed.
|
||||
Observed string `json:"observed,omitempty"`
|
||||
// FormsHostUnresolvable reports that the install's own forms host does not
|
||||
// resolve, which is an operator problem and not the customer's typo.
|
||||
FormsHostUnresolvable bool `json:"forms_host_unresolvable"`
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package models
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// NullableTime distinguishes an absent JSON field from an explicit null in
|
||||
@@ -27,3 +29,26 @@ func (n NullableTime) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
return json.Marshal(n.Value)
|
||||
}
|
||||
|
||||
// NullableUUID distinguishes an absent JSON field from an explicit null in
|
||||
// PATCH payloads. Absent leaves the column untouched; null clears it.
|
||||
type NullableUUID struct {
|
||||
Set bool `json:"-"`
|
||||
Value *uuid.UUID `json:"-"`
|
||||
}
|
||||
|
||||
func (n *NullableUUID) UnmarshalJSON(b []byte) error {
|
||||
n.Set = true
|
||||
if string(b) == "null" {
|
||||
n.Value = nil
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(b, &n.Value)
|
||||
}
|
||||
|
||||
func (n NullableUUID) MarshalJSON() ([]byte, error) {
|
||||
if n.Value == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.Marshal(n.Value)
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ var SegmentFieldCatalog = []SegmentFieldSpec{
|
||||
{Field: "phone", Label: "Phone", Group: "Contact", Kind: SegmentFieldText},
|
||||
{Field: "subscribed", Label: "Subscribed", Group: "Contact", Kind: SegmentFieldBool},
|
||||
{Field: "suppressed", Label: "On the suppression list", Group: "Contact", Kind: SegmentFieldBool},
|
||||
{Field: "source", Label: "Source", Group: "Contact", Kind: SegmentFieldEnum, Options: []string{"unknown", "manual", "campaign", "import", "sheet_sync", "api", "ai_assistant"}},
|
||||
{Field: "source", Label: "Source", Group: "Contact", Kind: SegmentFieldEnum, Options: []string{"unknown", "manual", "campaign", "import", "sheet_sync", "api", "ai_assistant", "form"}},
|
||||
{Field: "verification_status", Label: "Verification status", Group: "Contact", Kind: SegmentFieldEnum, Options: []string{"valid", "risky", "invalid", "unknown"}},
|
||||
{Field: "is_catch_all", Label: "Catch-all domain", Group: "Contact", Kind: SegmentFieldBool},
|
||||
{Field: "esp_provider", Label: "Email provider", Group: "Contact", Kind: SegmentFieldEnum, Options: []string{"gmail", "outlook", "other"}},
|
||||
|
||||
@@ -76,6 +76,9 @@ const (
|
||||
WebhookEventContactUpdated WebhookEventType = "contact.updated"
|
||||
WebhookEventContactDeleted WebhookEventType = "contact.deleted"
|
||||
|
||||
// Forms.
|
||||
WebhookEventFormSubmitted WebhookEventType = "form.submitted"
|
||||
|
||||
// --- Bulk import/export operations ---
|
||||
WebhookEventBulkOperationStarted WebhookEventType = "bulk_operation.started"
|
||||
WebhookEventBulkOperationCompleted WebhookEventType = "bulk_operation.completed"
|
||||
@@ -165,6 +168,7 @@ var AllWebhookEventTypes = []WebhookEventType{
|
||||
WebhookEventContactCreated,
|
||||
WebhookEventContactUpdated,
|
||||
WebhookEventContactDeleted,
|
||||
WebhookEventFormSubmitted,
|
||||
WebhookEventBulkOperationStarted,
|
||||
WebhookEventBulkOperationCompleted,
|
||||
WebhookEventBulkOperationFailed,
|
||||
@@ -301,6 +305,7 @@ func buildWebhookEventCatalog() []WebhookEventDescriptor {
|
||||
{WebhookEventContactCreated, WebhookCatContact, "A contact was created."},
|
||||
{WebhookEventContactUpdated, WebhookCatContact, "A contact was updated."},
|
||||
{WebhookEventContactDeleted, WebhookCatContact, "A contact was deleted."},
|
||||
{WebhookEventFormSubmitted, WebhookCatContact, "A hosted form received a submission."},
|
||||
{WebhookEventCRMDealCreated, WebhookCatCRM, "A CRM deal was created."},
|
||||
{WebhookEventCRMDealUpdated, WebhookCatCRM, "A CRM deal changed (including stage moves)."},
|
||||
{WebhookEventCRMDealDeleted, WebhookCatCRM, "A CRM deal was deleted."},
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// Issue #267: forms CRUD, the category aggregation scan and the submission
|
||||
// counter transaction, proven against the real schema.
|
||||
//
|
||||
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
|
||||
// go test ./internal/repository/ -run LiveForm -v
|
||||
|
||||
func TestLiveFormLifecycle(t *testing.T) {
|
||||
handle, pool := liveContactDB(t)
|
||||
f := newSharedOrgFixture(t, pool)
|
||||
repo := NewFormRepository(handle)
|
||||
ctx := context.Background()
|
||||
|
||||
category := uuid.New()
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO categories (id, user_id, title, color, position) VALUES ($1, $2, 'Form leads', '#00ff00', 0)`, category, f.owner); err != nil {
|
||||
t.Fatalf("fixture category: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
c := context.Background()
|
||||
if _, err := pool.Exec(c, `DELETE FROM forms WHERE organization_id = $1`, f.org); err != nil {
|
||||
t.Errorf("cleanup forms: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(c, `DELETE FROM categories WHERE id = $1`, category); err != nil {
|
||||
t.Errorf("cleanup category: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
created, xerr := repo.Create(ctx, f.org, &f.owner, &models.Form{
|
||||
PublicID: "livetest-" + uuid.New().String()[:13],
|
||||
Name: "Live test form",
|
||||
Status: models.FormStatusDraft,
|
||||
Fields: []models.FormField{{ID: "email", Type: models.FormFieldEmail, Label: "Email", Required: true, MapTo: "email"}},
|
||||
SuccessMessage: "Thanks",
|
||||
CategoryIDs: []uuid.UUID{category},
|
||||
AllowedDomains: []string{"example.com"},
|
||||
})
|
||||
if xerr != nil {
|
||||
t.Fatalf("create: %v", xerr)
|
||||
}
|
||||
if len(created.CategoryIDs) != 1 || created.CategoryIDs[0] != category {
|
||||
t.Fatalf("category aggregation scan: %+v", created.CategoryIDs)
|
||||
}
|
||||
if len(created.AllowedDomains) != 1 || created.AllowedDomains[0] != "example.com" {
|
||||
t.Fatalf("allowed domains round-trip: %+v", created.AllowedDomains)
|
||||
}
|
||||
|
||||
// Publish through Update; the campaign link is org-scoped.
|
||||
created.Status = models.FormStatusPublished
|
||||
created.CampaignID = &f.campaign
|
||||
updated, xerr := repo.Update(ctx, f.org, created)
|
||||
if xerr != nil {
|
||||
t.Fatalf("update: %v", xerr)
|
||||
}
|
||||
if updated.Status != models.FormStatusPublished || updated.CampaignID == nil {
|
||||
t.Fatalf("update round-trip: %+v", updated)
|
||||
}
|
||||
|
||||
byPublic, xerr := repo.GetByPublicID(ctx, created.PublicID)
|
||||
if xerr != nil {
|
||||
t.Fatalf("get by public id: %v", xerr)
|
||||
}
|
||||
if byPublic.ID != created.ID {
|
||||
t.Fatal("public id resolved the wrong form")
|
||||
}
|
||||
|
||||
if xerr := repo.RecordView(ctx, created.ID); xerr != nil {
|
||||
t.Fatalf("record view: %v", xerr)
|
||||
}
|
||||
sub, xerr := repo.CreateSubmission(ctx, &models.FormSubmission{
|
||||
FormID: created.ID,
|
||||
OrganizationID: f.org,
|
||||
ContactID: &f.contact,
|
||||
Data: map[string]any{"email": "live@test.dev", "topics": []string{"a", "b"}},
|
||||
SourceURL: "https://example.com/pricing",
|
||||
})
|
||||
if xerr != nil {
|
||||
t.Fatalf("create submission: %v", xerr)
|
||||
}
|
||||
if xerr := repo.LogSubmissionActivity(ctx, f.org, f.contact, created.ID, created.Name, sub.ID); xerr != nil {
|
||||
t.Fatalf("log activity: %v", xerr)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if _, err := pool.Exec(context.Background(), `DELETE FROM contact_activities WHERE contact_id = $1 AND activity_type = 'form_submitted'`, f.contact); err != nil {
|
||||
t.Errorf("cleanup activities: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
after, xerr := repo.Get(ctx, f.org, created.ID)
|
||||
if xerr != nil {
|
||||
t.Fatalf("get after submit: %v", xerr)
|
||||
}
|
||||
if after.ViewsCount != 1 || after.SubmissionsCount != 1 || after.LastSubmissionAt == nil {
|
||||
t.Fatalf("counters: views=%d subs=%d last=%v", after.ViewsCount, after.SubmissionsCount, after.LastSubmissionAt)
|
||||
}
|
||||
|
||||
subs, hasMore, xerr := repo.ListSubmissions(ctx, f.org, created.ID, 10, nil)
|
||||
if xerr != nil {
|
||||
t.Fatalf("list submissions: %v", xerr)
|
||||
}
|
||||
if len(subs) != 1 || hasMore {
|
||||
t.Fatalf("list: %d hasMore=%v", len(subs), hasMore)
|
||||
}
|
||||
if subs[0].ContactEmail == "" {
|
||||
t.Fatal("contact join missing")
|
||||
}
|
||||
if got, ok := subs[0].Data["topics"].([]any); !ok || len(got) != 2 {
|
||||
t.Fatalf("data round-trip: %#v", subs[0].Data["topics"])
|
||||
}
|
||||
|
||||
if xerr := repo.DeleteSubmission(ctx, f.org, created.ID, subs[0].ID); xerr != nil {
|
||||
t.Fatalf("delete submission: %v", xerr)
|
||||
}
|
||||
after, xerr = repo.Get(ctx, f.org, created.ID)
|
||||
if xerr != nil {
|
||||
t.Fatalf("get after delete: %v", xerr)
|
||||
}
|
||||
if after.SubmissionsCount != 0 {
|
||||
t.Fatalf("counter after delete: %d", after.SubmissionsCount)
|
||||
}
|
||||
|
||||
// Tenancy: another org must see nothing.
|
||||
if _, xerr := repo.Get(ctx, f.other, created.ID); xerr == nil {
|
||||
t.Fatal("cross-org read succeeded")
|
||||
}
|
||||
if xerr := repo.Delete(ctx, f.org, created.ID); xerr != nil {
|
||||
t.Fatalf("delete: %v", xerr)
|
||||
}
|
||||
}
|
||||
@@ -3261,7 +3261,7 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
|
||||
FROM contact_activities
|
||||
WHERE contact_id = $1
|
||||
AND organization_id = $2
|
||||
AND activity_type IN ('contact_created', 'campaign_added', 'campaign_removed', 'category_added', 'category_removed')
|
||||
AND activity_type IN ('contact_created', 'campaign_added', 'campaign_removed', 'category_added', 'category_removed', 'form_submitted')
|
||||
AND created_at < $3
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $4
|
||||
@@ -3305,6 +3305,9 @@ func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID,
|
||||
case models.TimelineCategoryAdded, models.TimelineCategoryRemoved:
|
||||
ev.CategoryID = id("category_id")
|
||||
ev.CategoryTitle = str("category_title")
|
||||
case models.TimelineFormSubmitted:
|
||||
ev.FormID = id("form_id")
|
||||
ev.FormName = str("form_name")
|
||||
}
|
||||
events = append(events, ev)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
type FormRepository interface {
|
||||
List(ctx context.Context, orgID uuid.UUID) ([]models.Form, *errx.Error)
|
||||
Get(ctx context.Context, orgID, id uuid.UUID) (*models.Form, *errx.Error)
|
||||
// GetByPublicID serves the public page; the unguessable token is the
|
||||
// capability, so there is no org filter.
|
||||
GetByPublicID(ctx context.Context, publicID string) (*models.Form, *errx.Error)
|
||||
Create(ctx context.Context, orgID uuid.UUID, createdBy *uuid.UUID, f *models.Form) (*models.Form, *errx.Error)
|
||||
Update(ctx context.Context, orgID uuid.UUID, f *models.Form) (*models.Form, *errx.Error)
|
||||
Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error
|
||||
// UpdateAssets sets the uploaded brand asset URLs; a nil pointer leaves
|
||||
// that column untouched.
|
||||
UpdateAssets(ctx context.Context, orgID, id uuid.UUID, logoURL, coverURL, backgroundURL *string) (*models.Form, *errx.Error)
|
||||
// RecordView bumps the view counter; best-effort, called on public GETs.
|
||||
RecordView(ctx context.Context, formID uuid.UUID) *errx.Error
|
||||
// CreateSubmission stores the answers and bumps the form's counters.
|
||||
CreateSubmission(ctx context.Context, sub *models.FormSubmission) (*models.FormSubmission, *errx.Error)
|
||||
ListSubmissions(ctx context.Context, orgID, formID uuid.UUID, limit int, before *time.Time) ([]models.FormSubmission, bool, *errx.Error)
|
||||
// LogSubmissionActivity adds the form_submitted event to the contact
|
||||
// timeline; best-effort, called after the submission is stored.
|
||||
LogSubmissionActivity(ctx context.Context, orgID, contactID, formID uuid.UUID, formName string, submissionID uuid.UUID) *errx.Error
|
||||
DeleteSubmission(ctx context.Context, orgID, formID, subID uuid.UUID) *errx.Error
|
||||
}
|
||||
|
||||
type formRepository struct {
|
||||
DB *db.DB
|
||||
}
|
||||
|
||||
func NewFormRepository(d *db.DB) FormRepository {
|
||||
return &formRepository{DB: d}
|
||||
}
|
||||
|
||||
const formColumns = `f.id, f.organization_id, f.created_by, f.public_id, f.name, f.status, f.fields, f.design,
|
||||
f.success_message, f.redirect_url, f.campaign_id, f.allowed_domains, f.captcha_enabled,
|
||||
f.logo_url, f.cover_url, f.background_url,
|
||||
f.views_count, f.submissions_count, f.last_submission_at, f.published_at,
|
||||
(SELECT COALESCE(array_agg(fc.category_id), ARRAY[]::uuid[]) FROM form_categories fc WHERE fc.form_id = f.id),
|
||||
f.created_at, f.updated_at`
|
||||
|
||||
func scanForm(row pgx.Row) (*models.Form, error) {
|
||||
var f models.Form
|
||||
var fieldsRaw, designRaw []byte
|
||||
if err := row.Scan(&f.ID, &f.OrganizationID, &f.CreatedBy, &f.PublicID, &f.Name, &f.Status, &fieldsRaw, &designRaw,
|
||||
&f.SuccessMessage, &f.RedirectURL, &f.CampaignID, &f.AllowedDomains, &f.CaptchaEnabled,
|
||||
&f.LogoURL, &f.CoverURL, &f.BackgroundURL,
|
||||
&f.ViewsCount, &f.SubmissionsCount, &f.LastSubmissionAt, &f.PublishedAt,
|
||||
&f.CategoryIDs, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.Fields = []models.FormField{}
|
||||
if len(fieldsRaw) > 0 {
|
||||
if err := json.Unmarshal(fieldsRaw, &f.Fields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(designRaw) > 0 {
|
||||
if err := json.Unmarshal(designRaw, &f.Design); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if f.AllowedDomains == nil {
|
||||
f.AllowedDomains = []string{}
|
||||
}
|
||||
if f.CategoryIDs == nil {
|
||||
f.CategoryIDs = []uuid.UUID{}
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (r *formRepository) List(ctx context.Context, orgID uuid.UUID) ([]models.Form, *errx.Error) {
|
||||
rows, err := r.DB.Query(ctx, `SELECT `+formColumns+` FROM forms f WHERE f.organization_id = $1 ORDER BY f.created_at DESC`, orgID)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "forms list", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.Form{}
|
||||
for rows.Next() {
|
||||
f, err := scanForm(rows)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "forms list", nil, "scan")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *formRepository) Get(ctx context.Context, orgID, id uuid.UUID) (*models.Form, *errx.Error) {
|
||||
f, err := scanForm(r.DB.QueryRow(ctx, `SELECT `+formColumns+` FROM forms f WHERE f.organization_id = $1 AND f.id = $2`, orgID, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errx.New(errx.NotFound, "form not found")
|
||||
}
|
||||
db.CaptureError(err, "forms get", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (r *formRepository) GetByPublicID(ctx context.Context, publicID string) (*models.Form, *errx.Error) {
|
||||
f, err := scanForm(r.DB.QueryRow(ctx, `SELECT `+formColumns+` FROM forms f WHERE f.public_id = $1`, publicID))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errx.New(errx.NotFound, "form not found")
|
||||
}
|
||||
db.CaptureError(err, "forms get public", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (r *formRepository) Create(ctx context.Context, orgID uuid.UUID, createdBy *uuid.UUID, f *models.Form) (*models.Form, *errx.Error) {
|
||||
var count int
|
||||
if err := r.DB.QueryRow(ctx, `SELECT COUNT(*) FROM forms WHERE organization_id = $1`, orgID).Scan(&count); err != nil {
|
||||
db.CaptureError(err, "forms count", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if count >= models.FormsPerOrgMax {
|
||||
return nil, errx.New(errx.BadRequest, fmt.Sprintf("at most %d forms per organization", models.FormsPerOrgMax))
|
||||
}
|
||||
|
||||
fields, _ := json.Marshal(f.Fields)
|
||||
design, _ := json.Marshal(f.Design)
|
||||
|
||||
tx, err := r.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "forms create", nil, "begin")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var id uuid.UUID
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO forms (organization_id, created_by, public_id, name, status, fields, design,
|
||||
success_message, redirect_url, campaign_id, allowed_domains, captcha_enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id
|
||||
`, orgID, createdBy, f.PublicID, f.Name, f.Status, fields, design,
|
||||
f.SuccessMessage, f.RedirectURL, f.CampaignID, f.AllowedDomains, f.CaptchaEnabled).Scan(&id)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "forms create", nil, "insert")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if xerr := setFormCategories(ctx, tx, id, f.CategoryIDs); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
db.CaptureError(err, "forms create", nil, "commit")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return r.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (r *formRepository) Update(ctx context.Context, orgID uuid.UUID, f *models.Form) (*models.Form, *errx.Error) {
|
||||
fields, _ := json.Marshal(f.Fields)
|
||||
design, _ := json.Marshal(f.Design)
|
||||
|
||||
tx, err := r.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "forms update", nil, "begin")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE forms SET name = $3, status = $4, fields = $5, design = $6, success_message = $7,
|
||||
redirect_url = $8, campaign_id = $9, allowed_domains = $10, captcha_enabled = $11,
|
||||
published_at = $12, updated_at = NOW()
|
||||
WHERE organization_id = $1 AND id = $2
|
||||
`, orgID, f.ID, f.Name, f.Status, fields, design, f.SuccessMessage,
|
||||
f.RedirectURL, f.CampaignID, f.AllowedDomains, f.CaptchaEnabled, f.PublishedAt)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "forms update", nil, "exec")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil, errx.New(errx.NotFound, "form not found")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM form_categories WHERE form_id = $1`, f.ID); err != nil {
|
||||
db.CaptureError(err, "forms update", nil, "categories clear")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if xerr := setFormCategories(ctx, tx, f.ID, f.CategoryIDs); xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
db.CaptureError(err, "forms update", nil, "commit")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return r.Get(ctx, orgID, f.ID)
|
||||
}
|
||||
|
||||
// setFormCategories links the picked categories, quietly dropping ids that
|
||||
// no longer exist (a stale picker must not fail the save).
|
||||
func setFormCategories(ctx context.Context, tx pgx.Tx, formID uuid.UUID, categoryIDs []uuid.UUID) *errx.Error {
|
||||
if len(categoryIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(categoryIDs) > models.FormMaxCategories {
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("at most %d categories per form", models.FormMaxCategories))
|
||||
}
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO form_categories (form_id, category_id)
|
||||
SELECT $1, c.id FROM categories c WHERE c.id = ANY($2)
|
||||
ON CONFLICT (form_id, category_id) DO NOTHING
|
||||
`, formID, categoryIDs)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "forms categories", nil, "insert")
|
||||
return errx.InternalError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *formRepository) Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error {
|
||||
tag, err := r.DB.Exec(ctx, `DELETE FROM forms WHERE organization_id = $1 AND id = $2`, orgID, id)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "forms delete", nil, "exec")
|
||||
return errx.InternalError()
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errx.New(errx.NotFound, "form not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *formRepository) UpdateAssets(ctx context.Context, orgID, id uuid.UUID, logoURL, coverURL, backgroundURL *string) (*models.Form, *errx.Error) {
|
||||
tag, err := r.DB.Exec(ctx, `
|
||||
UPDATE forms SET
|
||||
logo_url = COALESCE($3, logo_url),
|
||||
cover_url = COALESCE($4, cover_url),
|
||||
background_url = COALESCE($5, background_url),
|
||||
updated_at = NOW()
|
||||
WHERE organization_id = $1 AND id = $2
|
||||
`, orgID, id, logoURL, coverURL, backgroundURL)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "forms assets", nil, "exec")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil, errx.New(errx.NotFound, "form not found")
|
||||
}
|
||||
return r.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (r *formRepository) RecordView(ctx context.Context, formID uuid.UUID) *errx.Error {
|
||||
if _, err := r.DB.Exec(ctx, `UPDATE forms SET views_count = views_count + 1 WHERE id = $1`, formID); err != nil {
|
||||
db.CaptureError(err, "forms view", nil, "exec")
|
||||
return errx.InternalError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *formRepository) CreateSubmission(ctx context.Context, sub *models.FormSubmission) (*models.FormSubmission, *errx.Error) {
|
||||
data, err := json.Marshal(sub.Data)
|
||||
if err != nil {
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
|
||||
tx, err := r.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form submission", nil, "begin")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO form_submissions (form_id, organization_id, contact_id, campaign_id, data, source_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at
|
||||
`, sub.FormID, sub.OrganizationID, sub.ContactID, sub.CampaignID, data, sub.SourceURL).Scan(&sub.ID, &sub.CreatedAt)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form submission", nil, "insert")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE forms SET submissions_count = submissions_count + 1, last_submission_at = NOW() WHERE id = $1
|
||||
`, sub.FormID); err != nil {
|
||||
db.CaptureError(err, "form submission", nil, "counters")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
db.CaptureError(err, "form submission", nil, "commit")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (r *formRepository) ListSubmissions(ctx context.Context, orgID, formID uuid.UUID, limit int, before *time.Time) ([]models.FormSubmission, bool, *errx.Error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := r.DB.Query(ctx, `
|
||||
SELECT s.id, s.form_id, s.organization_id, s.contact_id, s.campaign_id, s.data, s.source_url, s.created_at,
|
||||
COALESCE(c.email, ''), COALESCE(TRIM(c.first_name || ' ' || c.last_name), ''), COALESCE(cp.name, '')
|
||||
FROM form_submissions s
|
||||
LEFT JOIN contacts c ON c.id = s.contact_id
|
||||
LEFT JOIN campaigns cp ON cp.id = s.campaign_id
|
||||
WHERE s.organization_id = $1 AND s.form_id = $2 AND ($3::timestamptz IS NULL OR s.created_at < $3)
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT $4
|
||||
`, orgID, formID, before, limit+1)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form submissions list", nil, "query")
|
||||
return nil, false, errx.InternalError()
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.FormSubmission{}
|
||||
for rows.Next() {
|
||||
var s models.FormSubmission
|
||||
var data []byte
|
||||
if err := rows.Scan(&s.ID, &s.FormID, &s.OrganizationID, &s.ContactID, &s.CampaignID, &data, &s.SourceURL, &s.CreatedAt,
|
||||
&s.ContactEmail, &s.ContactName, &s.CampaignName); err != nil {
|
||||
db.CaptureError(err, "form submissions list", nil, "scan")
|
||||
return nil, false, errx.InternalError()
|
||||
}
|
||||
s.Data = map[string]any{}
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &s.Data); err != nil {
|
||||
db.CaptureError(err, "form submissions list", nil, "unmarshal")
|
||||
return nil, false, errx.InternalError()
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
hasMore := len(out) > limit
|
||||
if hasMore {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
func (r *formRepository) DeleteSubmission(ctx context.Context, orgID, formID, subID uuid.UUID) *errx.Error {
|
||||
tx, err := r.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form submission delete", nil, "begin")
|
||||
return errx.InternalError()
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
DELETE FROM form_submissions WHERE organization_id = $1 AND form_id = $2 AND id = $3
|
||||
`, orgID, formID, subID)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form submission delete", nil, "exec")
|
||||
return errx.InternalError()
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errx.New(errx.NotFound, "submission not found")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE forms SET submissions_count = GREATEST(submissions_count - 1, 0) WHERE id = $1
|
||||
`, formID); err != nil {
|
||||
db.CaptureError(err, "form submission delete", nil, "counters")
|
||||
return errx.InternalError()
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
db.CaptureError(err, "form submission delete", nil, "commit")
|
||||
return errx.InternalError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *formRepository) LogSubmissionActivity(ctx context.Context, orgID, contactID, formID uuid.UUID, formName string, submissionID uuid.UUID) *errx.Error {
|
||||
_, err := r.DB.Exec(ctx, `
|
||||
INSERT INTO contact_activities (contact_id, organization_id, user_id, activity_type, metadata)
|
||||
VALUES ($1, $2, NULL, 'form_submitted',
|
||||
jsonb_build_object('form_id', $3::uuid, 'form_name', $4::text, 'submission_id', $5::uuid))
|
||||
`, contactID, orgID, formID, formName, submissionID)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form activity", nil, "exec")
|
||||
return errx.InternalError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// FormAggregates are the per-form event rollups the forms list attaches.
|
||||
type FormAggregates struct {
|
||||
Starts int64
|
||||
Identified int64
|
||||
// Trend is daily submit counts, oldest first.
|
||||
Trend []int64
|
||||
}
|
||||
|
||||
// FormEventRepository stores and aggregates funnel events. Queries mirror
|
||||
// pg_analytics.go: GROUP BY date with sparse days, gap-filled by the caller.
|
||||
type FormEventRepository interface {
|
||||
Insert(ctx context.Context, ev *models.FormEvent) *errx.Error
|
||||
Totals(ctx context.Context, orgID, formID uuid.UUID, from time.Time) (*models.FormStatsTotals, *errx.Error)
|
||||
DailySeries(ctx context.Context, orgID, formID uuid.UUID, from time.Time) ([]models.FormStatsDay, *errx.Error)
|
||||
// PageFunnel rolls up per-visitor progress; pages is the form's current
|
||||
// page count so the funnel always covers every page, visited or not.
|
||||
PageFunnel(ctx context.Context, orgID, formID uuid.UUID, from time.Time, pages int) ([]models.FormFunnelPage, *errx.Error)
|
||||
Breakdown(ctx context.Context, orgID, formID uuid.UUID, from time.Time, column string, limit int) ([]models.FormStatsBucket, *errx.Error)
|
||||
// CampaignBreakdown buckets views by the campaign that carried the
|
||||
// personalized link, keyed by campaign name.
|
||||
CampaignBreakdown(ctx context.Context, orgID, formID uuid.UUID, from time.Time, limit int) ([]models.FormStatsBucket, *errx.Error)
|
||||
RecentIdentified(ctx context.Context, orgID, formID uuid.UUID, from time.Time, limit int) ([]models.FormIdentifiedVisitor, *errx.Error)
|
||||
// ListAggregates rolls up starts/identified totals and a daily submit
|
||||
// trend (trendDays, oldest first) for every form of the org in one pass.
|
||||
ListAggregates(ctx context.Context, orgID uuid.UUID, trendDays int) (map[uuid.UUID]*FormAggregates, *errx.Error)
|
||||
// CampaignFormPerformance rolls up every form this campaign has handed a
|
||||
// personalized link to, with what recipients did with it.
|
||||
CampaignFormPerformance(ctx context.Context, orgID, campaignID uuid.UUID) ([]models.CampaignFormStats, *errx.Error)
|
||||
PruneBefore(ctx context.Context, before time.Time) (int64, *errx.Error)
|
||||
}
|
||||
|
||||
type formEventRepository struct {
|
||||
DB *db.DB
|
||||
}
|
||||
|
||||
func NewFormEventRepository(d *db.DB) FormEventRepository {
|
||||
return &formEventRepository{DB: d}
|
||||
}
|
||||
|
||||
func (r *formEventRepository) Insert(ctx context.Context, ev *models.FormEvent) *errx.Error {
|
||||
_, err := r.DB.Exec(ctx, `
|
||||
INSERT INTO form_events (organization_id, form_id, event_type, visitor_key,
|
||||
page_index, pages_total, contact_id, campaign_id, referrer_domain, country_code, device)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`, ev.OrganizationID, ev.FormID, ev.Type, ev.VisitorKey,
|
||||
ev.PageIndex, ev.PagesTotal, ev.ContactID, ev.CampaignID, ev.ReferrerDomain, ev.CountryCode, ev.Device)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form events insert", nil, "exec")
|
||||
return errx.InternalError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *formEventRepository) Totals(ctx context.Context, orgID, formID uuid.UUID, from time.Time) (*models.FormStatsTotals, *errx.Error) {
|
||||
var t models.FormStatsTotals
|
||||
err := r.DB.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FILTER (WHERE event_type = 'view'),
|
||||
COUNT(*) FILTER (WHERE event_type = 'start'),
|
||||
COUNT(*) FILTER (WHERE event_type = 'submit'),
|
||||
COUNT(DISTINCT contact_id) FILTER (WHERE contact_id IS NOT NULL)
|
||||
FROM form_events
|
||||
WHERE organization_id = $1 AND form_id = $2 AND occurred_at >= $3
|
||||
`, orgID, formID, from).Scan(&t.Views, &t.Starts, &t.Submissions, &t.IdentifiedVisitors)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form events totals", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
if t.Starts > 0 {
|
||||
t.CompletionRate = float64(t.Submissions) / float64(t.Starts)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (r *formEventRepository) DailySeries(ctx context.Context, orgID, formID uuid.UUID, from time.Time) ([]models.FormStatsDay, *errx.Error) {
|
||||
rows, err := r.DB.Query(ctx, `
|
||||
SELECT occurred_at::date::text,
|
||||
COUNT(*) FILTER (WHERE event_type = 'view'),
|
||||
COUNT(*) FILTER (WHERE event_type = 'start'),
|
||||
COUNT(*) FILTER (WHERE event_type = 'submit')
|
||||
FROM form_events
|
||||
WHERE organization_id = $1 AND form_id = $2 AND occurred_at >= $3
|
||||
GROUP BY occurred_at::date
|
||||
ORDER BY occurred_at::date ASC
|
||||
`, orgID, formID, from)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form events daily", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.FormStatsDay{}
|
||||
for rows.Next() {
|
||||
var d models.FormStatsDay
|
||||
if err := rows.Scan(&d.Date, &d.Views, &d.Starts, &d.Submissions); err != nil {
|
||||
db.CaptureError(err, "form events daily", nil, "scan")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *formEventRepository) PageFunnel(ctx context.Context, orgID, formID uuid.UUID, from time.Time, pages int) ([]models.FormFunnelPage, *errx.Error) {
|
||||
if pages < 1 {
|
||||
pages = 1
|
||||
}
|
||||
rows, err := r.DB.Query(ctx, `
|
||||
WITH visitors AS (
|
||||
SELECT visitor_key,
|
||||
COALESCE(MAX(page_index) FILTER (WHERE event_type = 'page'), 0) AS max_page,
|
||||
BOOL_OR(event_type = 'submit') AS submitted
|
||||
FROM form_events
|
||||
WHERE organization_id = $1 AND form_id = $2 AND occurred_at >= $3 AND visitor_key <> ''
|
||||
GROUP BY visitor_key
|
||||
)
|
||||
SELECT gs.idx,
|
||||
(SELECT COUNT(*) FROM visitors v WHERE v.max_page >= gs.idx),
|
||||
(SELECT COUNT(*) FROM visitors v WHERE v.max_page >= gs.idx AND v.submitted)
|
||||
FROM generate_series(0, $4 - 1) AS gs(idx)
|
||||
ORDER BY gs.idx
|
||||
`, orgID, formID, from, pages)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form events funnel", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.FormFunnelPage{}
|
||||
for rows.Next() {
|
||||
var p models.FormFunnelPage
|
||||
if err := rows.Scan(&p.PageIndex, &p.Reached, &p.CompletedFrom); err != nil {
|
||||
db.CaptureError(err, "form events funnel", nil, "scan")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *formEventRepository) Breakdown(ctx context.Context, orgID, formID uuid.UUID, from time.Time, column string, limit int) ([]models.FormStatsBucket, *errx.Error) {
|
||||
// The column is picked from a fixed set, never interpolated from input.
|
||||
var col string
|
||||
switch column {
|
||||
case "referrer_domain":
|
||||
col = "referrer_domain"
|
||||
case "country_code":
|
||||
col = "country_code"
|
||||
case "device":
|
||||
col = "device"
|
||||
default:
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
rows, err := r.DB.Query(ctx, `
|
||||
SELECT `+col+`, COUNT(*)
|
||||
FROM form_events
|
||||
WHERE organization_id = $1 AND form_id = $2 AND occurred_at >= $3
|
||||
AND event_type = 'view' AND `+col+` <> '' AND `+col+` <> 'unknown'
|
||||
GROUP BY `+col+`
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT $4
|
||||
`, orgID, formID, from, limit)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form events breakdown", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.FormStatsBucket{}
|
||||
for rows.Next() {
|
||||
var b models.FormStatsBucket
|
||||
if err := rows.Scan(&b.Key, &b.Count); err != nil {
|
||||
db.CaptureError(err, "form events breakdown", nil, "scan")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *formEventRepository) CampaignBreakdown(ctx context.Context, orgID, formID uuid.UUID, from time.Time, limit int) ([]models.FormStatsBucket, *errx.Error) {
|
||||
rows, err := r.DB.Query(ctx, `
|
||||
SELECT COALESCE(cp.name, 'Deleted campaign'), COUNT(*)
|
||||
FROM form_events e
|
||||
LEFT JOIN campaigns cp ON cp.id = e.campaign_id
|
||||
WHERE e.organization_id = $1 AND e.form_id = $2 AND e.occurred_at >= $3
|
||||
AND e.event_type = 'view' AND e.campaign_id IS NOT NULL
|
||||
GROUP BY cp.name
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT $4
|
||||
`, orgID, formID, from, limit)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form events campaigns", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.FormStatsBucket{}
|
||||
for rows.Next() {
|
||||
var b models.FormStatsBucket
|
||||
if err := rows.Scan(&b.Key, &b.Count); err != nil {
|
||||
db.CaptureError(err, "form events campaigns", nil, "scan")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *formEventRepository) RecentIdentified(ctx context.Context, orgID, formID uuid.UUID, from time.Time, limit int) ([]models.FormIdentifiedVisitor, *errx.Error) {
|
||||
rows, err := r.DB.Query(ctx, `
|
||||
SELECT e.contact_id,
|
||||
COALESCE(NULLIF(TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')), ''), c.email),
|
||||
c.email,
|
||||
MAX(e.occurred_at),
|
||||
COALESCE(MAX(e.page_index) FILTER (WHERE e.event_type = 'page'), 0),
|
||||
BOOL_OR(e.event_type = 'submit'),
|
||||
COALESCE(MAX(cp.name), '')
|
||||
FROM form_events e
|
||||
JOIN contacts c ON c.id = e.contact_id
|
||||
LEFT JOIN campaigns cp ON cp.id = e.campaign_id
|
||||
WHERE e.organization_id = $1 AND e.form_id = $2 AND e.occurred_at >= $3 AND e.contact_id IS NOT NULL
|
||||
GROUP BY e.contact_id, c.first_name, c.last_name, c.email
|
||||
ORDER BY MAX(e.occurred_at) DESC
|
||||
LIMIT $4
|
||||
`, orgID, formID, from, limit)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form events identified", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.FormIdentifiedVisitor{}
|
||||
for rows.Next() {
|
||||
var v models.FormIdentifiedVisitor
|
||||
if err := rows.Scan(&v.ContactID, &v.Name, &v.Email, &v.LastSeen, &v.FurthestPage, &v.Completed, &v.Campaign); err != nil {
|
||||
db.CaptureError(err, "form events identified", nil, "scan")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *formEventRepository) ListAggregates(ctx context.Context, orgID uuid.UUID, trendDays int) (map[uuid.UUID]*FormAggregates, *errx.Error) {
|
||||
out := map[uuid.UUID]*FormAggregates{}
|
||||
get := func(id uuid.UUID) *FormAggregates {
|
||||
if a, ok := out[id]; ok {
|
||||
return a
|
||||
}
|
||||
a := &FormAggregates{Trend: make([]int64, trendDays)}
|
||||
out[id] = a
|
||||
return a
|
||||
}
|
||||
|
||||
rows, err := r.DB.Query(ctx, `
|
||||
SELECT form_id,
|
||||
COUNT(*) FILTER (WHERE event_type = 'start'),
|
||||
COUNT(DISTINCT contact_id) FILTER (WHERE contact_id IS NOT NULL)
|
||||
FROM form_events
|
||||
WHERE organization_id = $1
|
||||
GROUP BY form_id
|
||||
`, orgID)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form events aggregates", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var starts, identified int64
|
||||
if err := rows.Scan(&id, &starts, &identified); err != nil {
|
||||
rows.Close()
|
||||
db.CaptureError(err, "form events aggregates", nil, "scan")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
a := get(id)
|
||||
a.Starts, a.Identified = starts, identified
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
rows, err = r.DB.Query(ctx, `
|
||||
SELECT form_id, (occurred_at AT TIME ZONE 'UTC')::date, COUNT(*)
|
||||
FROM form_events
|
||||
WHERE organization_id = $1 AND event_type = 'submit'
|
||||
AND occurred_at >= NOW() - make_interval(days => $2)
|
||||
GROUP BY form_id, (occurred_at AT TIME ZONE 'UTC')::date
|
||||
`, orgID, trendDays)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form events trend", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
defer rows.Close()
|
||||
today := time.Now().UTC().Truncate(24 * time.Hour)
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var day time.Time
|
||||
var count int64
|
||||
if err := rows.Scan(&id, &day, &count); err != nil {
|
||||
db.CaptureError(err, "form events trend", nil, "scan")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
slot := trendDays - 1 - int(today.Sub(day.Truncate(24*time.Hour)).Hours()/24)
|
||||
if slot >= 0 && slot < trendDays {
|
||||
get(id).Trend[slot] = count
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *formEventRepository) CampaignFormPerformance(ctx context.Context, orgID, campaignID uuid.UUID) ([]models.CampaignFormStats, *errx.Error) {
|
||||
rows, err := r.DB.Query(ctx, `
|
||||
WITH links AS (
|
||||
SELECT form_id, COUNT(*) AS links_sent
|
||||
FROM form_links
|
||||
WHERE organization_id = $1 AND campaign_id = $2
|
||||
GROUP BY form_id
|
||||
), ev AS (
|
||||
SELECT form_id,
|
||||
COUNT(DISTINCT visitor_key) FILTER (WHERE event_type = 'view') AS viewers,
|
||||
COUNT(DISTINCT visitor_key) FILTER (WHERE event_type = 'start') AS starters
|
||||
FROM form_events
|
||||
WHERE organization_id = $1 AND campaign_id = $2
|
||||
GROUP BY form_id
|
||||
), sub AS (
|
||||
SELECT form_id, COUNT(*) AS submissions
|
||||
FROM form_submissions
|
||||
WHERE organization_id = $1 AND campaign_id = $2
|
||||
GROUP BY form_id
|
||||
), refd AS (
|
||||
-- Forms written into a step but not sent yet still belong in the
|
||||
-- panel, at zero. Keep the pattern in sync with FormLinkMarkerRE.
|
||||
SELECT DISTINCT m[1] AS public_id
|
||||
FROM sequences q,
|
||||
regexp_matches(COALESCE(q.subject, '') || ' ' || COALESCE(q.body_html, ''),
|
||||
'\{\{\s*form_link:([a-z0-9]{1,64})\s*\}\}', 'g') AS m
|
||||
WHERE q.campaign_id = $2 AND q.organization_id = $1
|
||||
)
|
||||
SELECT f.id, f.name, f.public_id, f.status,
|
||||
COALESCE(l.links_sent, 0), COALESCE(e.viewers, 0),
|
||||
COALESCE(e.starters, 0), COALESCE(s.submissions, 0)
|
||||
FROM forms f
|
||||
LEFT JOIN links l ON l.form_id = f.id
|
||||
LEFT JOIN ev e ON e.form_id = f.id
|
||||
LEFT JOIN sub s ON s.form_id = f.id
|
||||
WHERE f.organization_id = $1
|
||||
AND (l.form_id IS NOT NULL OR e.form_id IS NOT NULL OR s.form_id IS NOT NULL
|
||||
OR f.public_id IN (SELECT public_id FROM refd))
|
||||
ORDER BY COALESCE(s.submissions, 0) DESC, COALESCE(l.links_sent, 0) DESC, f.name
|
||||
`, orgID, campaignID)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "campaign form performance", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.CampaignFormStats{}
|
||||
for rows.Next() {
|
||||
var s models.CampaignFormStats
|
||||
if err := rows.Scan(&s.FormID, &s.FormName, &s.PublicID, &s.Status,
|
||||
&s.LinksSent, &s.Viewers, &s.Starters, &s.Submissions); err != nil {
|
||||
db.CaptureError(err, "campaign form performance", nil, "scan")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *formEventRepository) PruneBefore(ctx context.Context, before time.Time) (int64, *errx.Error) {
|
||||
tag, err := r.DB.Exec(ctx, `DELETE FROM form_events WHERE occurred_at < $1`, before)
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form events prune", nil, "exec")
|
||||
return 0, errx.InternalError()
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// FormLinkRepository stores per-contact form URL tickets. The row id is the
|
||||
// opaque token that travels in ?t=; there is no signing secret anywhere.
|
||||
type FormLinkRepository interface {
|
||||
// Upsert returns the stable token for (form, contact). The insert is
|
||||
// guarded against the contact's org so a foreign contact id cannot be
|
||||
// linked; campaign_id keeps the first non-null value.
|
||||
Upsert(ctx context.Context, orgID, formID, contactID uuid.UUID, campaignID *uuid.UUID) (uuid.UUID, *errx.Error)
|
||||
// Resolve is the public path: token to link, no org filter (the uuid is
|
||||
// the capability, like tracked_links).
|
||||
Resolve(ctx context.Context, token uuid.UUID) (*models.FormLink, *errx.Error)
|
||||
}
|
||||
|
||||
type formLinkRepository struct {
|
||||
DB *db.DB
|
||||
}
|
||||
|
||||
func NewFormLinkRepository(d *db.DB) FormLinkRepository {
|
||||
return &formLinkRepository{DB: d}
|
||||
}
|
||||
|
||||
func (r *formLinkRepository) Upsert(ctx context.Context, orgID, formID, contactID uuid.UUID, campaignID *uuid.UUID) (uuid.UUID, *errx.Error) {
|
||||
var id uuid.UUID
|
||||
err := r.DB.QueryRow(ctx, `
|
||||
INSERT INTO form_links (form_id, organization_id, contact_id, campaign_id)
|
||||
SELECT $2, $1, c.id, $4 FROM contacts c WHERE c.id = $3 AND c.organization_id = $1
|
||||
ON CONFLICT (form_id, contact_id)
|
||||
DO UPDATE SET campaign_id = COALESCE(form_links.campaign_id, EXCLUDED.campaign_id)
|
||||
RETURNING id
|
||||
`, orgID, formID, contactID, campaignID).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, errx.New(errx.NotFound, "contact not found")
|
||||
}
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form links upsert", nil, "query")
|
||||
return uuid.Nil, errx.InternalError()
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *formLinkRepository) Resolve(ctx context.Context, token uuid.UUID) (*models.FormLink, *errx.Error) {
|
||||
var l models.FormLink
|
||||
err := r.DB.QueryRow(ctx, `
|
||||
SELECT id, form_id, organization_id, contact_id, campaign_id, created_at
|
||||
FROM form_links WHERE id = $1
|
||||
`, token).Scan(&l.ID, &l.FormID, &l.OrganizationID, &l.ContactID, &l.CampaignID, &l.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errx.New(errx.NotFound, "link not found")
|
||||
}
|
||||
if err != nil {
|
||||
db.CaptureError(err, "form links resolve", nil, "query")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
@@ -34,6 +34,14 @@ type OrganizationRepository interface {
|
||||
AddMember(ctx context.Context, member *models.OrganizationMember) error
|
||||
UpdateMember(ctx context.Context, member *models.OrganizationMember) error
|
||||
|
||||
// Custom forms domain (implemented in pg_organization_forms_domain.go)
|
||||
GetFormsDomain(ctx context.Context, orgID uuid.UUID) (domain string, verified bool, err error)
|
||||
SetFormsDomain(ctx context.Context, orgID uuid.UUID, domain string) error
|
||||
SetFormsDomainVerified(ctx context.Context, orgID uuid.UUID, verified bool, at *time.Time) error
|
||||
// ListFormsDomains returns every org with a custom forms domain set, for
|
||||
// the periodic re-verification sweep.
|
||||
ListFormsDomains(ctx context.Context) ([]OrgFormsDomain, error)
|
||||
|
||||
// Custom roles (implemented in pg_organization_roles.go)
|
||||
ListRoles(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationRole, error)
|
||||
GetRoleByID(ctx context.Context, orgID, roleID uuid.UUID) (*models.OrganizationRole, error)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OrgFormsDomain is one organization's custom forms domain, for the sweep.
|
||||
type OrgFormsDomain struct {
|
||||
OrganizationID uuid.UUID
|
||||
Domain string
|
||||
Verified bool
|
||||
}
|
||||
|
||||
func (r *organizationRepository) GetFormsDomain(ctx context.Context, orgID uuid.UUID) (string, bool, error) {
|
||||
var domain string
|
||||
var verified bool
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT forms_domain, forms_domain_verified FROM organizations WHERE id = $1
|
||||
`, orgID).Scan(&domain, &verified)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return domain, verified, nil
|
||||
}
|
||||
|
||||
// SetFormsDomain stores a new value and clears the verdict: the old one
|
||||
// describes a name that is no longer configured.
|
||||
func (r *organizationRepository) SetFormsDomain(ctx context.Context, orgID uuid.UUID, domain string) error {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
UPDATE organizations
|
||||
SET forms_domain = $2, forms_domain_verified = false, forms_domain_verified_at = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
`, orgID, domain)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *organizationRepository) SetFormsDomainVerified(ctx context.Context, orgID uuid.UUID, verified bool, at *time.Time) error {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
UPDATE organizations
|
||||
SET forms_domain_verified = $2, forms_domain_verified_at = $3, updated_at = now()
|
||||
WHERE id = $1
|
||||
`, orgID, verified, at)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *organizationRepository) ListFormsDomains(ctx context.Context) ([]OrgFormsDomain, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT id, forms_domain, forms_domain_verified
|
||||
FROM organizations
|
||||
WHERE forms_domain <> '' AND deletion_scheduled_for IS NULL
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []OrgFormsDomain{}
|
||||
for rows.Next() {
|
||||
var d OrgFormsDomain
|
||||
if err := rows.Scan(&d.OrganizationID, &d.Domain, &d.Verified); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Dev-org forms ("dddd0f" block).
|
||||
var (
|
||||
devFormDemo = uuid.MustParse("33333333-0000-0000-0000-000000000001")
|
||||
devFormNewsletter = uuid.MustParse("33333333-0000-0000-0000-000000000002")
|
||||
devFormContact = uuid.MustParse("33333333-0000-0000-0000-000000000003")
|
||||
)
|
||||
|
||||
// devFormDemoPublicID is referenced from the draft campaign's step, so the
|
||||
// campaign Forms panel has something to report before anything is sent.
|
||||
const devFormDemoPublicID = "bkademe23456789abcdef"
|
||||
|
||||
func devFormLinkID(i int) uuid.UUID {
|
||||
return uuid.MustParse(fmt.Sprintf("dddd0f00-0000-0000-0000-%012d", i))
|
||||
}
|
||||
|
||||
func devFormEventID(journey, step int) uuid.UUID {
|
||||
return uuid.MustParse(fmt.Sprintf("dddd0f01-0000-0000-0000-%06d%06d", journey, step))
|
||||
}
|
||||
|
||||
func devFormSubmissionID(i int) uuid.UUID {
|
||||
return uuid.MustParse(fmt.Sprintf("dddd0f02-0000-0000-0000-%012d", i))
|
||||
}
|
||||
|
||||
// seedDevForms builds the hosted-forms fixture: a three-page demo form on the
|
||||
// split layout, a one-question newsletter form in focus mode and a draft,
|
||||
// plus the personalized links, funnel events and submissions that make the
|
||||
// analytics, responses and campaign panels show real shapes.
|
||||
func seedDevForms(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
demoFields := `[
|
||||
{"id":"f_head","type":"heading","label":"Book a demo","required":false},
|
||||
{"id":"f_intro","type":"paragraph","label":"","value":"Twenty minutes, no slides. We will look at your current sending setup together.","required":false},
|
||||
{"id":"f_first","type":"text","label":"First name","required":true,"map_to":"first_name","width":"half"},
|
||||
{"id":"f_last","type":"text","label":"Last name","required":false,"map_to":"last_name","width":"half"},
|
||||
{"id":"f_email","type":"email","label":"Work email","required":true,"map_to":"email"},
|
||||
{"id":"f_break1","type":"page_break","label":"About your team","required":false},
|
||||
{"id":"f_company","type":"text","label":"Company","required":false,"map_to":"company"},
|
||||
{"id":"f_size","type":"select","label":"How many people send outbound?","required":true,"options":["Just me","2-10","11-50","50+"]},
|
||||
{"id":"f_mailboxes","type":"number","label":"Mailboxes in rotation","required":false},
|
||||
{"id":"f_break2","type":"page_break","label":"Anything else","required":false},
|
||||
{"id":"f_note","type":"textarea","label":"What would you like to cover?","required":false,"rows":4},
|
||||
{"id":"f_terms","type":"checkbox","label":"Terms","placeholder":"I agree to be contacted about this request","required":true}
|
||||
]`
|
||||
demoDesign := `{"theme":"ocean","layout":"split","mode":"classic","align":"left","show_progress":true,
|
||||
"font_family":"sora","page_background":"#0c4a6e","page_background_end":"#082f49","form_background":"#ffffff",
|
||||
"text_color":"#0f172a","label_color":"#334155","input_background":"#ffffff","input_border_color":"#e2e8f0",
|
||||
"input_text_color":"#0f172a","placeholder_color":"#94a3b8","accent_color":"#0284c7",
|
||||
"button_background":"#0284c7","button_text_color":"#ffffff","button_text":"Book my demo",
|
||||
"border_radius":12,"shadow":true}`
|
||||
|
||||
newsletterFields := `[
|
||||
{"id":"n_email","type":"email","label":"Email","required":true,"map_to":"email"},
|
||||
{"id":"n_topic","type":"radio","label":"What should we send you?","required":true,"options":["Product news","Deliverability tips","Both"]}
|
||||
]`
|
||||
newsletterDesign := `{"theme":"midnight","layout":"card","mode":"focus","align":"center","show_progress":true,
|
||||
"font_family":"inter","page_background":"#0f172a","form_background":"#1e293b","text_color":"#f1f5f9",
|
||||
"label_color":"#cbd5e1","input_background":"#0f172a","input_border_color":"#334155","input_text_color":"#f1f5f9",
|
||||
"placeholder_color":"#64748b","accent_color":"#38bdf8","button_background":"#38bdf8","button_text_color":"#0f172a",
|
||||
"button_text":"Subscribe","border_radius":12,"shadow":false}`
|
||||
|
||||
contactFields := `[
|
||||
{"id":"c_name","type":"text","label":"Name","required":true,"map_to":"first_name"},
|
||||
{"id":"c_email","type":"email","label":"Email","required":true,"map_to":"email"},
|
||||
{"id":"c_msg","type":"textarea","label":"How can we help?","required":true,"rows":5}
|
||||
]`
|
||||
|
||||
forms := []struct {
|
||||
id uuid.UUID
|
||||
publicID string
|
||||
name string
|
||||
status string
|
||||
fields string
|
||||
design string
|
||||
views int64
|
||||
ageDays int
|
||||
}{
|
||||
{devFormDemo, devFormDemoPublicID, "Book a demo", "published", demoFields, demoDesign, 412, 35},
|
||||
{devFormNewsletter, "newsjxyzsignup2345678", "Newsletter signup", "published", newsletterFields, newsletterDesign, 168, 52},
|
||||
{devFormContact, "cntactus23456789abcde", "Contact us", "draft", contactFields, `{"theme":"minimal","layout":"card","mode":"classic"}`, 0, 3},
|
||||
}
|
||||
|
||||
for _, f := range forms {
|
||||
var publishedAt any
|
||||
if f.status == "published" {
|
||||
publishedAt = fmt.Sprintf("%d days", f.ageDays-1)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO forms (
|
||||
id, organization_id, created_by, public_id, name, status, fields, design,
|
||||
success_message, captcha_enabled, views_count, published_at, created_at, updated_at
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,false,$10,
|
||||
CASE WHEN $11::text IS NULL THEN NULL ELSE now() - $11::interval END,
|
||||
now() - make_interval(days => $12), now()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
public_id = EXCLUDED.public_id,
|
||||
name = EXCLUDED.name,
|
||||
status = EXCLUDED.status,
|
||||
fields = EXCLUDED.fields,
|
||||
design = EXCLUDED.design,
|
||||
views_count = EXCLUDED.views_count,
|
||||
updated_at = now()
|
||||
`, f.id, DevOrgID, DevUserID, f.publicID, f.name, f.status, f.fields, f.design,
|
||||
"Thanks! We will be in touch shortly.", f.views, publishedAt, f.ageDays); err != nil {
|
||||
return fmt.Errorf("form %s: %w", f.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Demo-form leads are filed under Leads; newsletter signups under Customers.
|
||||
for _, b := range []struct {
|
||||
form, category uuid.UUID
|
||||
}{
|
||||
{devFormDemo, devCategoryLead},
|
||||
{devFormNewsletter, devCategoryCustomer},
|
||||
} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO form_categories (form_id, category_id) VALUES ($1,$2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, b.form, b.category); err != nil {
|
||||
return fmt.Errorf("form category: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return seedDevFormFunnel(ctx, pool)
|
||||
}
|
||||
|
||||
// seedDevFormFunnel walks seven contacts of the active campaign through the
|
||||
// demo form: all of them opened their personalized link, most started, fewer
|
||||
// reached the later pages and three finished. The ones who stalled are what
|
||||
// the responses table shows as "in progress".
|
||||
func seedDevFormFunnel(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
journeys := []struct {
|
||||
contact int
|
||||
furthestPage int
|
||||
submitted bool
|
||||
country string
|
||||
device string
|
||||
}{
|
||||
{0, 2, true, "US", "desktop"},
|
||||
{1, 2, true, "GB", "mobile"},
|
||||
{2, 2, true, "US", "desktop"},
|
||||
{3, 1, false, "DE", "desktop"},
|
||||
{4, 1, false, "US", "mobile"},
|
||||
{5, 0, false, "CA", "tablet"},
|
||||
{6, 0, false, "US", "desktop"},
|
||||
}
|
||||
|
||||
for i, j := range journeys {
|
||||
contact := devContactID(j.contact)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO form_links (id, form_id, organization_id, contact_id, campaign_id, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5, now() - make_interval(days => $6))
|
||||
ON CONFLICT (form_id, contact_id) DO NOTHING
|
||||
`, devFormLinkID(i+1), devFormDemo, DevOrgID, contact, DevCampaignActiveID, 20-i); err != nil {
|
||||
return fmt.Errorf("form link %d: %w", i, err)
|
||||
}
|
||||
|
||||
// A journey is a view, then a start, then one page event per page
|
||||
// reached, exactly as the hosted app reports them.
|
||||
type ev struct {
|
||||
kind string
|
||||
page int
|
||||
}
|
||||
events := []ev{{"view", 0}}
|
||||
if j.furthestPage > 0 || j.submitted {
|
||||
events = append(events, ev{"start", 0})
|
||||
}
|
||||
for p := 1; p <= j.furthestPage; p++ {
|
||||
events = append(events, ev{"page", p})
|
||||
}
|
||||
if j.submitted {
|
||||
events = append(events, ev{"submit", j.furthestPage})
|
||||
}
|
||||
|
||||
for k, e := range events {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO form_events (
|
||||
id, organization_id, form_id, event_type, occurred_at, visitor_key,
|
||||
page_index, pages_total, contact_id, campaign_id, referrer_domain, country_code, device
|
||||
) VALUES (
|
||||
$1,$2,$3,$4, now() - make_interval(days => $5, mins => $6), $7,$8,3,$9,$10,
|
||||
'mail.google.com',$11,$12
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, devFormEventID(i+1, k+1), DevOrgID, devFormDemo, e.kind, 19-i, 90-k*9,
|
||||
fmt.Sprintf("dev-vk-%d", i+1), e.page, contact, DevCampaignActiveID,
|
||||
j.country, j.device); err != nil {
|
||||
return fmt.Errorf("form event %d/%d: %w", i, k, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !j.submitted {
|
||||
continue
|
||||
}
|
||||
c := devContacts[j.contact]
|
||||
data := fmt.Sprintf(
|
||||
`{"f_first":%q,"f_last":%q,"f_email":%q,"f_company":%q,"f_size":"11-50","f_note":"Mostly worried about landing in spam as we scale.","f_terms":"true"}`,
|
||||
c.first, c.last, devContactEmail(c), c.company)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO form_submissions (
|
||||
id, form_id, organization_id, contact_id, campaign_id, data, source_url, created_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7, now() - make_interval(days => $8, hours => 2))
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, devFormSubmissionID(i+1), devFormDemo, DevOrgID, contact, DevCampaignActiveID,
|
||||
data, "https://forms.warmbly.com/f/"+devFormDemoPublicID, 18-i); err != nil {
|
||||
return fmt.Errorf("form submission %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the denormalized counters in step with the rows written above.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE forms f SET
|
||||
submissions_count = (SELECT COUNT(*) FROM form_submissions s WHERE s.form_id = f.id),
|
||||
last_submission_at = (SELECT MAX(created_at) FROM form_submissions s WHERE s.form_id = f.id)
|
||||
WHERE f.organization_id = $1
|
||||
`, DevOrgID); err != nil {
|
||||
return fmt.Errorf("form counters: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user