Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 45 additions & 55 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,74 +1,64 @@
# =============================================================================
# CanvasFlow Environment Variables
# =============================================================================
#
# Copy this file to `.env` at the repo root and fill in the values.
# `setup.sh` will hard-link the root .env into every workspace
# (apps/* and packages/*) so all processes share one source of truth.
#
# cp .env.example .env
# bash setup.sh

# -----------------------------------------------------------------------------
# API Server (apps/api)
# -----------------------------------------------------------------------------
# API Server
PORT=8000
NODE_ENV=development
BASE_URL=http://localhost:8000

# -----------------------------------------------------------------------------
# Database (packages/database)
# PostgreSQL connection string.
# Format: postgresql://<user>:<password>@<host>:<port>/<dbname>
#
# Local Postgres via docker-compose:
# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dev
#
# Neon (recommended — free tier works):
# DATABASE_URL=postgresql://user:pass@host.neon.tech/db?sslmode=require
# -----------------------------------------------------------------------------
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dev
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/dev
POSTGRES_PORT=5434
DB_POOL_MAX=25
DB_STATEMENT_TIMEOUT_MS=10000

# Clustering
CLUSTER_WORKERS=0

# Rate Limiting
RATE_LIMIT_PUBLIC_WRITE_MAX=60
RATE_LIMIT_AUTH_MAX=300
RATE_LIMIT_UPLOAD_MAX=30

# Redis
REDIS_URL=redis://localhost:6379
REDIS_PORT=6379
REDIS_PREFIX=cf

# Cloudinary
CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
CLOUDINARY_FOLDER=canvasflow

# File Uploads
UPLOAD_TMP_DIR=
UPLOAD_MAX_MB=10
UPLOAD_MAX_MB_IMAGE=10
UPLOAD_MAX_MB_VIDEO=100
UPLOAD_MAX_MB_RAW=10
UPLOAD_WORKER_CONCURRENCY=8

# Analytics Queue
ANALYTICS_BATCH_MS=250
ANALYTICS_BATCH_MAX=500
ANALYTICS_WORKER_CONCURRENCY=4
ANALYTICS_INSERT_CHUNK=500

# -----------------------------------------------------------------------------
# Better Auth (packages/trpc/server/auth.ts)
# BETTER_AUTH_SECRET signs sessions/tokens — change it before deploying.
# Generate one with: openssl rand -base64 32
# -----------------------------------------------------------------------------
# Better Auth
BETTER_AUTH_SECRET=replace-me-with-an-openssl-rand-base64-32-value
BETTER_AUTH_URL=http://localhost:8000
WEB_URL=http://localhost:3000

# -----------------------------------------------------------------------------
# Google OAuth (optional)
# Both keys must be set to enable the "Continue with Google" button;
# otherwise the server skips the provider gracefully.
# Get credentials at: https://console.cloud.google.com/apis/credentials
# -----------------------------------------------------------------------------
# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# -----------------------------------------------------------------------------
# GitHub OAuth (optional)
# Same all-or-nothing rule as Google.
# Register an OAuth app at: https://github.com/settings/developers
# -----------------------------------------------------------------------------
# GitHub OAuth
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

# -----------------------------------------------------------------------------
# Web App (apps/web)
# -----------------------------------------------------------------------------
# Web App
NEXT_PUBLIC_API_URL=http://localhost:8000

# -----------------------------------------------------------------------------
# Production deployment (subdomain split: web on `canvasflow.<root>`,
# api on `api.canvasflow.<root>`). Leave blank in development.
# -----------------------------------------------------------------------------
# Extra origins allowed by CORS and Better Auth (comma-separated).
# Example: TRUSTED_ORIGINS=https://canvasflow.dittya.dev
# Production Split / CORS
TRUSTED_ORIGINS=
# Cookie domain shared by web + api subdomains so the auth session is
# readable across both. Leave unset in dev — letting it default to the
# request host is correct for localhost.
# Example: COOKIE_DOMAIN=.canvasflow.dittya.dev
COOKIE_DOMAIN=
SKIP_ENV_VALIDATION=
73 changes: 15 additions & 58 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,48 +23,15 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm build

- name: Stage release bundle
- name: Create release zip
run: |
set -eux
mkdir -p _release/web _release/api _release/db

# Web: Next.js standalone is self-contained; just copy static + public next to it.
cp -r apps/web/.next/standalone/. _release/web/
mkdir -p _release/web/apps/web/.next
cp -r apps/web/.next/static _release/web/apps/web/.next/static
cp -r apps/web/public _release/web/apps/web/public

# API: pnpm deploy produces a self-contained dir (dist + node_modules + package.json)
pnpm --filter @repo/api deploy --prod _release/api
# DB: same trick, plus the drizzle SQL + migrate.mjs.
pnpm --filter @repo/database deploy --prod _release/db
cp -r packages/database/drizzle _release/db/drizzle
cp packages/database/migrate.mjs _release/db/migrate.mjs

cp ecosystem.config.cjs _release/ecosystem.config.cjs
zip -r release.zip . -x "node_modules/*" ".git/*" ".next/*" "apps/web/.next/*" "apps/api/dist/*" "stress-tests/*"

- name: Write .env from secret
env:
PROD_ENV: ${{ secrets.PROD_ENV }}
run: |
# File is world-readable on the runner so the scp-action's
# Docker container can read it. It only lives here for ~10s
# before the runner is destroyed. Once it lands on the VM
# the next step chmod's it back to 600.
printf '%s\n' "$PROD_ENV" > _release/.env
printf '%s\n' "$PROD_ENV" > _env

- name: Copy files to VM
uses: appleboy/scp-action@v0.1.7
Expand All @@ -73,12 +40,10 @@ jobs:
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
source: "_release/*,_release/.env"
source: "release.zip,_env"
target: "/home/dittya/projects/canvasflow"
strip_components: 1
overwrite: true

- name: Migrate + reload PM2
- name: Deploy via Docker Compose
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
Expand All @@ -88,30 +53,22 @@ jobs:
script: |
set -eu
cd /home/dittya/projects/canvasflow

# Extract release bundle
unzip -o release.zip
rm release.zip
mv _env .env
chmod 600 .env

# dotenv reads .env from cwd in each process — symlink so
# both api and web (and the migrator) all find it.
ln -sf "$PWD/.env" api/.env
ln -sf "$PWD/.env" web/apps/web/.env
ln -sf "$PWD/.env" db/.env

# Apply pending DB migrations. If this fails, pm2 reload is
# never reached and the running app keeps serving the old code.
set -a; . ./.env; set +a
(cd db && node migrate.mjs)
# Build and deploy Docker containers
docker compose -f docker-compose.prod.yml up --build -d

# Start or reload PM2.
if pm2 describe canvasflow-api >/dev/null 2>&1; then
pm2 reload ecosystem.config.cjs --update-env
else
pm2 start ecosystem.config.cjs
pm2 save
fi
# Cleanup builder artifacts and dangling cache
docker image prune -f

- name: Summary
if: success()
run: |
echo "### ✅ Deployed from \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "### ✅ Deployed from \`${{ github.sha }}\` via Docker Compose" >> "$GITHUB_STEP_SUMMARY"
echo "- web: https://canvasflow.dittya.dev" >> "$GITHUB_STEP_SUMMARY"
echo "- api: https://api.canvasflow.dittya.dev" >> "$GITHUB_STEP_SUMMARY"
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ yarn-error.log*
.DS_Store
*.pem

.kiro
.kiro
stress-tests/
Empty file removed .npmrc
Empty file.
69 changes: 46 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Drag fields onto an open canvas, connect them like nodes, watch responses light
- **Real-time analytics.** Response timeline, device breakdown, day-of-week, completion rate, submissions table with virtualisation and CSV export.
- **One submission per visitor.** Partial unique index on `(form_id, visitor_id)` with a client-side lockout screen — visitors can't submit twice even by clearing localStorage.
- **Idempotency on every submit.** A client-generated `idempotency_key` collapses double-clicks and network retries into a single record.
- **Pro-tier paywall** for detailed analytics, with Free / Pro / Pro+ / Business tiers wired into the API and UI.
- **Segments and conditional branching.** Forms split into pages, with if/else rules that weigh several answers at once and route to a question, a segment, or the end.
- **Optimistic-lock versioning** on forms and fields so concurrent edits surface conflicts instead of silently overwriting.

## Tech stack
Expand Down Expand Up @@ -59,9 +59,9 @@ The `services` package is framework-agnostic — all SQL, validation, and busine

- **Node ≥ 20** (`engines` is pinned)
- **pnpm 9**
- A PostgreSQL database. Either:
- a free [Neon](https://neon.tech) project (recommended), or
- the bundled `docker-compose.yml` for local Postgres on port 5432.
- **Docker** — the bundled `docker-compose.yml` runs Postgres 15 for local
development. Any other PostgreSQL server works too; just point
`DATABASE_URL` at it and skip `pnpm db:up`.

### 1. Install

Expand All @@ -86,8 +86,12 @@ PORT=8000
NODE_ENV=development
BASE_URL=http://localhost:8000

# Database (Neon or local Postgres)
DATABASE_URL=postgresql://user:password@host:5432/dbname
# Database — matches the bundled docker-compose Postgres.
# POSTGRES_PORT is the host port compose publishes and must match the
# port in DATABASE_URL. 5434 (not 5432) so it can coexist with other
# Postgres containers on the same machine.
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/dev
POSTGRES_PORT=5434

# Better Auth
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
Expand All @@ -107,20 +111,27 @@ GITHUB_CLIENT_SECRET=
### 3. Set up the database

```sh
# Optional: spin up local Postgres
docker compose up -d

# Generate + apply Drizzle migrations
pnpm db:generate
pnpm db:migrate
pnpm db:up # start the Postgres container, wait until it's healthy
pnpm db:migrate # apply the committed Drizzle migrations
```

`pnpm db:up` uses `docker compose up -d --wait`, so it doesn't return until
Postgres actually accepts TCP connections. Without that wait, a `db:migrate`
fired immediately after start loses the race and fails with `ECONNREFUSED`.

You only need `pnpm db:generate` when you've _changed_ `schema.ts` and want a
new migration file — it is not part of first-time setup.

### 4. Run dev

```sh
pnpm dev
```

`dev` runs `db:up` first, so the database is guaranteed to be listening
before the API boots. Use `pnpm dev:no-db` if you're pointing
`DATABASE_URL` at a server you manage yourself.

This boots, in parallel:

| URL | What |
Expand All @@ -136,27 +147,39 @@ Sign up at `/signUp`, build a form, publish it, share `/forms/<id>`, watch respo

All scripts are turbo-orchestrated and `dotenv -- ...` wrapped so workspaces share the root env.

| Script | What it does |
| ------------------ | ------------------------------------------------ |
| `pnpm dev` | Run every workspace's dev task |
| `pnpm build` | Build the API and the web app for production |
| `pnpm lint` | ESLint across all workspaces (zero-warning) |
| `pnpm check-types` | TypeScript no-emit type-check |
| `pnpm format` | Prettier across `**/*.{ts,tsx,md}` |
| `pnpm db:generate` | Generate a Drizzle migration from schema changes |
| `pnpm db:migrate` | Apply pending migrations |
| Script | What it does |
| ------------------ | --------------------------------------------------- |
| `pnpm dev` | Start Postgres, then run every workspace's dev task |
| `pnpm dev:no-db` | Same, without touching Docker |
| `pnpm build` | Build the API and the web app for production |
| `pnpm lint` | ESLint across all workspaces (zero-warning) |
| `pnpm check-types` | TypeScript no-emit type-check |
| `pnpm format` | Prettier across `**/*.{ts,tsx,md}` |
| `pnpm db:up` | Start the Postgres container, wait until healthy |
| `pnpm db:down` | Stop it, keeping the data volume |
| `pnpm db:logs` | Tail Postgres logs |
| `pnpm db:psql` | Open a `psql` shell inside the container |
| `pnpm db:reset` | **Destroys the volume**, recreates, re-migrates |
| `pnpm db:generate` | Generate a Drizzle migration from schema changes |
| `pnpm db:migrate` | Apply pending migrations |

`db:migrate` and `db:generate` are marked `"cache": false` in `turbo.json`.
They mutate a database / write files, so a turbo cache hit would report
"migrations applied successfully" while doing nothing — which silently
leaves the schema behind whenever you switch `DATABASE_URL` to a different
server.

Filter to a single workspace with `pnpm -F web <script>` or `pnpm -F @repo/api <script>`.

## Design decisions worth knowing

- **Single-statement dashboard query.** `form.getDashboardStats` is one SQL `WITH owned AS (...)` CTE that returns `forms`, totals, per-form counts, and the 90-day trend as JSON in a single round-trip. The pg pool is also pre-warmed with four sockets at boot, so the first burst of concurrent queries doesn't pay parallel TLS handshakes to Neon. Result: dashboard loads in ~250ms warm and stays under 300ms even when fired alongside other authed calls.
- **Single-statement dashboard query.** `form.getDashboardStats` is one SQL `WITH owned AS (...)` CTE that returns `forms`, totals, per-form counts, and the 90-day trend as JSON in a single round-trip. The pg pool is also pre-warmed with four sockets at boot, so the first burst of concurrent queries doesn't pay parallel TLS handshakes against a remote server. Result: dashboard loads in ~250ms warm and stays under 300ms even when fired alongside other authed calls.
- **`Server-Timing` headers** are emitted from every authed tRPC procedure (`auth;dur=… inner;dur=…`). Visible in DevTools Network panel — useful for diagnosing whether a slow request was auth or query.
- **React Query staleTime caching** on dashboard / list / analytics hooks (30–60s) so in-app back-navigation paints instantly. Mutations always `invalidate()` on success, so stale data can't survive a real write.
- **Visitor lockout** is enforced in three places: the UI hides the form behind a `cf_submitted_<formId>` localStorage flag, the API service does a `(form_id, visitor_id)` lookup before insert, and a partial unique index on the same tuple wins races at the DB level.
- **Field type validation in the public form** does format checks for `EMAIL` (`/^[^\s@]+@[^\s@]+\.[^\s@]+$/`) and `URL` (`new URL()`) — both block forward navigation and surface a sonner toast.
- **Code splitting.** Recharts widgets and the heaviest analytics components are loaded via `next/dynamic` per route. The submissions virtualised table mounts its detail modal only when a row is clicked.
- **Pricing tiers** are enforced server-side in `packages/services/form-submission/index.ts` (monthly submission caps) and `packages/trpc/server/trpc.ts` (`proAuthenticatedProcedure` for detailed analytics).
- **Question layout** is a per-form setting (`forms.question_layout`) with an `AUTO` default that reads the form's shape: one question per page until a second segment exists, then a page per segment. The author can override to one-question, one-segment, or everything-at-once.

## Repository layout

Expand Down
10 changes: 9 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"start": "node dist/index.js",
"dev": "dotenv -- tsx watch ./src/index.ts",
"build": "tsup",
"lint": "eslint src/*"
"lint": "eslint src/*",
"check-types": "tsc --noEmit"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
Expand All @@ -17,6 +18,7 @@
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/multer": "^1.4.12",
"@types/node": "^22.15.3",
"dotenv-cli": "^11.0.0",
"tsup": "^8.5.1",
Expand All @@ -25,7 +27,11 @@
},
"dependencies": {
"@better-auth/drizzle-adapter": "^1.6.20",
"@repo/database": "workspace:*",
"@repo/logger": "workspace:*",
"@repo/queue": "workspace:*",
"@repo/redis": "workspace:*",
"@repo/services": "workspace:*",
"@repo/trpc": "workspace:*",
"@scalar/express-api-reference": "^0.8.30",
"@trpc/server": "^11.8.1",
Expand All @@ -37,7 +43,9 @@
"drizzle-orm": "^0.45.1",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"multer": "2.0.2",
"pg": "^8.16.3",
"rate-limit-redis": "4.2.0",
"trpc-to-openapi": "^3.1.0",
"winston": "^3.19.0",
"zod": "^4.3.5"
Expand Down
Loading
Loading