diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 6f8b3170a..2419e25f3 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff8b41cf9..8d1192121 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 7a4b9d707..e5de9870f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `