diff --git a/.env.example b/.env.example index 5dec389..62d0d09 100644 --- a/.env.example +++ b/.env.example @@ -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://:@:/ -# -# 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.`, -# api on `api.canvasflow.`). 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= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ed5f683..27ab765 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 @@ -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 }} @@ -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" diff --git a/.gitignore b/.gitignore index f62cd8b..13cad58 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ yarn-error.log* .DS_Store *.pem -.kiro \ No newline at end of file +.kiro +stress-tests/ \ No newline at end of file diff --git a/.npmrc b/.npmrc deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index 4a0262c..8daa8ae 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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) @@ -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 | @@ -136,27 +147,39 @@ Sign up at `/signUp`, build a form, publish it, share `/forms/`, 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