diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 00000000..e7ef699e --- /dev/null +++ b/.env.local.example @@ -0,0 +1,7 @@ +# Personal env file for local development. Loaded by Vite for every mode, +# but mode-specific files (.env.staging, .env.staging.local, etc.) override it. +# +# Copy this file to `.env.local` and adjust as needed. The defaults below +# match the Supabase CLI's local stack (`supabase start`). +VITE_SUPABASE_URL=http://127.0.0.1:54321 +VITE_SUPABASE_PUBLISHABLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 diff --git a/.env.staging.example b/.env.staging.example new file mode 100644 index 00000000..eb047aa8 --- /dev/null +++ b/.env.staging.example @@ -0,0 +1,4 @@ +# Staging Supabase project (used by `pnpm run dev:staging` and `pnpm run build:staging`) +# Copy this file to `.env.staging.local` and fill in the values. +VITE_SUPABASE_URL=https://your-staging-project.supabase.co +VITE_SUPABASE_PUBLISHABLE_KEY=your-staging-anon-key diff --git a/.github/workflows/db-migrate.yml b/.github/workflows/db-migrate.yml new file mode 100644 index 00000000..bfb09f84 --- /dev/null +++ b/.github/workflows/db-migrate.yml @@ -0,0 +1,81 @@ +name: DB Migrate + +on: + push: + branches: [main] + paths: + - "supabase/migrations/**" + pull_request: + branches: [main] + paths: + - "supabase/migrations/**" + - ".github/workflows/db-migrate.yml" + workflow_dispatch: + inputs: + target: + description: "Which environment to migrate" + required: true + type: choice + options: [staging, prod] + +permissions: + contents: read + pull-requests: write + +jobs: + migrate: + name: Push migrations + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: ${{ ((github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'prod') || github.event_name == 'push') && 'production' || 'staging' }} + + steps: + - uses: actions/checkout@v4 + + - uses: supabase/setup-cli@v1 + with: + version: 2.58.5 + + - name: Resolve target + id: target + run: | + case "${{ github.event_name }}" in + workflow_dispatch) TARGET="${{ github.event.inputs.target }}" ;; + pull_request) TARGET="staging" ;; + push) TARGET="prod" ;; + *) echo "Unknown event"; exit 1 ;; + esac + echo "target=$TARGET" >> "$GITHUB_OUTPUT" + echo "Migrating: $TARGET" + + - name: Push migrations + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + PROJECT_REF: ${{ steps.target.outputs.target == 'prod' && vars.PROD_PROJECT_REF || vars.STAGING_PROJECT_REF }} + DB_PASSWORD: ${{ steps.target.outputs.target == 'prod' && secrets.PROD_DB_PASSWORD || secrets.STAGING_DB_PASSWORD }} + run: | + supabase link --project-ref "$PROJECT_REF" --password "$DB_PASSWORD" + supabase db push --password "$DB_PASSWORD" + + - name: Update PR comment with migration status + if: always() && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OUTCOME: ${{ job.status }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + TARGET: ${{ steps.target.outputs.target || 'staging' }} + MARKER: "" + run: | + if [[ "$OUTCOME" == "success" ]]; then + BODY="$MARKER"$'\n'"✅ **DB Migrate succeeded** for \`$TARGET\` — [workflow run]($RUN_URL)." + else + BODY="$MARKER"$'\n'"❌ **DB Migrate failed** for \`$TARGET\` — [workflow run]($RUN_URL)." + fi + EXISTING=$(gh api "repos/$REPO/issues/$PR/comments" --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" | head -n1) + if [[ -n "$EXISTING" ]]; then + gh api -X PATCH "repos/$REPO/issues/comments/$EXISTING" -f body="$BODY" + else + gh pr comment "$PR" --body "$BODY" + fi diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index c028f23f..ca67ee68 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -2,14 +2,17 @@ name: E2E Tests on: push: - branches: [main, develop] + branches: [main] pull_request: - branches: [main, develop] + branches: [main] jobs: test: timeout-minutes: 60 runs-on: ubuntu-latest + env: + VITE_SUPABASE_URL: http://127.0.0.1:54321 + VITE_SUPABASE_PUBLISHABLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 strategy: fail-fast: false matrix: @@ -24,7 +27,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: ".nvmrc" cache: "pnpm" - name: Install dependencies @@ -65,7 +68,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: ".nvmrc" cache: "pnpm" - name: Install dependencies diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 035c62e4..b775a568 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,9 +2,9 @@ name: Lint on: push: - branches: [main, develop] + branches: [main] pull_request: - branches: [main, develop] + branches: [main] jobs: lint: @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: ".nvmrc" cache: "pnpm" - name: Install dependencies diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 50c551d4..22f44ce1 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -2,15 +2,18 @@ name: Unit Tests on: push: - branches: [main, develop] + branches: [main] pull_request: - branches: [main, develop] + branches: [main] jobs: test: name: Run Unit Tests runs-on: ubuntu-latest timeout-minutes: 10 + env: + VITE_SUPABASE_URL: http://127.0.0.1:54321 + VITE_SUPABASE_PUBLISHABLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 steps: - uses: actions/checkout@v4 @@ -19,7 +22,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version-file: ".nvmrc" cache: "pnpm" - name: Install dependencies diff --git a/.gitignore b/.gitignore index 9cd36c1e..9a3df1b3 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ supabase/.branches .vercel dev-dist -dump.sql \ No newline at end of file +dump.sql + +# Sync script credentials (DB connection strings — never commit) +scripts/.env.sync \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/docs/ENVIRONMENTS.md b/docs/ENVIRONMENTS.md new file mode 100644 index 00000000..d3993cdb --- /dev/null +++ b/docs/ENVIRONMENTS.md @@ -0,0 +1,92 @@ +# Environments + +| Env | Project | Used by | +| --- | --- | --- | +| **local** | Supabase CLI (`supabase start`) | `pnpm run dev` (default), e2e tests | +| **staging** | a second Supabase project | `pnpm run dev:staging`, Vercel preview deploys | +| **prod** | `qssmazlqrmxiudxckxvi` | Vercel production only | + +The frontend reads `VITE_SUPABASE_URL` / `VITE_SUPABASE_PUBLISHABLE_KEY` from a Vite env file picked by `--mode`. Vite load order (later overrides earlier): + +``` +.env -> .env.local -> .env.[mode] -> .env.[mode].local +``` + +`*.local` files are gitignored; the `*.example` files are templates. + +## Setting up a new Supabase project (e.g. staging) + +1. **Create the project** in the Supabase dashboard. +2. **Configure auth** (Authentication → Sign In / Providers → Email): + - "Confirm email" → **off** + - "Email OTP Length" → **6** + - Site URL and additional redirect URLs → match prod +3. **Paste the magic-link email template** (Authentication → Email Templates → Magic Link) from `supabase/templates/magic_link.html`. Re-paste on every change. +4. **Apply migrations**: GitHub → Actions → **DB Migrate** → Run workflow → target = `staging`. +5. **Save the database password** (Project Settings → Database). You'll need it for GitHub secrets and `scripts/.env.sync`. + +## GitHub Actions config + +Settings → Secrets and variables → Actions. + +**Variables:** + +| Name | Value | +| --- | --- | +| `PROD_PROJECT_REF` | `qssmazlqrmxiudxckxvi` | +| `STAGING_PROJECT_REF` | the staging project's ref | + +**Secrets:** + +| Name | Source | +| --- | --- | +| `SUPABASE_ACCESS_TOKEN` | https://supabase.com/dashboard/account/tokens | +| `PROD_DB_PASSWORD` | Project Settings → Database | +| `STAGING_DB_PASSWORD` | Same, on the staging project | + +Add a **required-reviewer** rule to the `production` GitHub environment (Settings → Environments → production) so prod migrations pause for approval. + +## Vercel config + +Project Settings → Environment Variables. For each Supabase var, add it twice: + +| Variable | Production scope (main) | Preview scope (everything else) | +| --- | --- | --- | +| `VITE_SUPABASE_URL` | prod URL | staging URL | +| `VITE_SUPABASE_PUBLISHABLE_KEY` | prod anon key | staging anon key | +| `VITE_PUBLIC_POSTHOG_KEY` | PostHog key | same | +| `VITE_PUBLIC_POSTHOG_HOST` | PostHog host | same | + +## Local prerequisites + +- **Supabase CLI** + **Docker** (for `supabase start`) +- **Postgres client tools** for the sync script: `brew install libpq` on macOS (and add `/opt/homebrew/opt/libpq/bin` to your PATH), `apt-get install postgresql-client` on Debian. +- Copy env templates: + ```bash + cp .env.local.example .env.local # local supabase + cp .env.staging.example .env.staging.local # staging + cp scripts/.env.sync.example scripts/.env.sync # prod + staging direct DB connection strings (for sync script) + ``` + +## Day-to-day commands + +```bash +pnpm run dev # local supabase (requires `supabase start`) +pnpm run dev:staging # staging +pnpm run db:sync:staging # overwrite staging public schema with prod data, anonymized +pnpm run db:sync:local # same, into local supabase +``` + +The sync script syncs `auth.users` (with anonymized emails, no passwords) and `public.*` (PII scrubbed via `scripts/anonymize.sql`). Existing target users on `auth.users` are preserved via `ON CONFLICT DO NOTHING`. Skip auth sync with `SYNC_AUTH=0`. + +If you add a public-schema column that holds free-form user input or PII, update `scripts/anonymize.sql`. + +## Migration flow + +- PR → `main`: Vercel preview points at staging. If the PR touches `supabase/migrations/**`, **DB Migrate** auto-pushes to staging. `supabase db push` is idempotent so re-runs on each commit are safe. +- Merge to `main`: **DB Migrate** auto-pushes to prod (gated by the `production` environment's reviewer rule, if configured). +- Manual: Actions → **DB Migrate** → Run workflow → pick target. + +## Auth email template + +`supabase/templates/magic_link.html` is the source of truth. `supabase/config.toml` wires it into local supabase automatically. For staging and prod, paste it into the dashboard manually after each change (Authentication → Email Templates → Magic Link). Automating this via the Supabase Management API is a future improvement. diff --git a/package.json b/package.json index e7b03d77..9b1d1c73 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "type": "module", "scripts": { "dev": "vite", + "dev:staging": "vite --mode staging", "build": "vite build", "build:dev": "vite build --mode development", + "build:staging": "vite build --mode staging", "lint": "oxlint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", @@ -24,6 +26,8 @@ "test:setup:full": "bash scripts/setup-local-supabase.sh", "types:generate": "supabase gen types typescript --project-id qssmazlqrmxiudxckxvi > src/integrations/supabase/types.ts", "types:generate:local": "supabase gen types typescript --local > src/integrations/supabase/types.ts", + "db:sync:staging": "bash scripts/sync-from-prod.sh staging", + "db:sync:local": "bash scripts/sync-from-prod.sh local", "typecheck": "tsc --noEmit --project tsconfig.app.json", "prepare": "husky" }, @@ -138,5 +142,9 @@ "prettier --write" ] }, - "packageManager": "pnpm@10.12.4" + "packageManager": "pnpm@10.12.4", + "engines": { + "node": ">=22 <23", + "pnpm": ">=10" + } } diff --git a/scripts/.env.sync.example b/scripts/.env.sync.example new file mode 100644 index 00000000..fc67e511 --- /dev/null +++ b/scripts/.env.sync.example @@ -0,0 +1,11 @@ +# Copy this file to scripts/.env.sync and fill in values. +# scripts/.env.sync is gitignored — never commit real connection strings. +# +# Find these in Supabase Dashboard -> Project Settings -> Database +# -> Connection string -> URI (use the "Direct connection" string, not the pooler). + +PROD_DB_URL="postgresql://postgres:PASSWORD@db.qssmazlqrmxiudxckxvi.supabase.co:5432/postgres" +STAGING_DB_URL="postgresql://postgres:PASSWORD@db.YOUR-STAGING-REF.supabase.co:5432/postgres" + +# Optional — defaults to the Supabase CLI's local DB. +# LOCAL_DB_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" diff --git a/scripts/anonymize.sql b/scripts/anonymize.sql new file mode 100644 index 00000000..93c93435 --- /dev/null +++ b/scripts/anonymize.sql @@ -0,0 +1,30 @@ +-- Scrubs PII from a freshly-restored prod dump (public schema only). +-- Run after restoring data into staging or local. Idempotent. +-- +-- auth.users IS synced separately by sync-from-prod.sh, with emails rewritten +-- to user-@example.test at load time and no password set. Existing +-- target accounts are preserved via ON CONFLICT (id) DO NOTHING. Skip the auth +-- sync entirely with SYNC_AUTH=0. +-- +-- Any new public-schema column that holds free-text user input should be +-- added below. + +BEGIN; + +-- profiles.username may contain a real handle, and profiles.email is UNIQUE +-- and would collide if a real user signs in to staging (the new auth.users +-- row created by Supabase would conflict with the synced profile's email). +-- Both replaced with synthetic values matching the auth.users anonymization. +UPDATE public.profiles + SET username = 'user_' || substring(id::text, 1, 8), + email = 'user-' || substring(id::text, 1, 8) || '@example.test'; + +-- artist_notes.note_content is free-form user text. Wipe it. +UPDATE public.artist_notes + SET note_content = '[redacted]'; + +-- group_invites.invite_token is a live secret — rotate so old links are dead. +UPDATE public.group_invites + SET invite_token = encode(gen_random_bytes(16), 'hex'); + +COMMIT; diff --git a/scripts/sync-from-prod.sh b/scripts/sync-from-prod.sh new file mode 100755 index 00000000..754ff0d8 --- /dev/null +++ b/scripts/sync-from-prod.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# +# Sync production data into staging or local for testing. +# +# Usage: +# pnpm run db:sync:staging +# pnpm run db:sync:local +# +# What it does: +# 1. Sync auth.users from prod into target. Existing target users are preserved +# (ON CONFLICT DO NOTHING); newly inserted rows have their emails rewritten +# to user-@example.test, no password, no OAuth metadata. +# 2. pg_dump the `public` schema (data only) from PROD_DB_URL, TRUNCATE the +# target's public tables, and restore the dump. +# 3. Run scripts/anonymize.sql against the target to scrub remaining PII in +# the public schema. +# +# Required env vars (put them in scripts/.env.sync, which is gitignored): +# PROD_DB_URL Postgres connection string for the prod project +# (Supabase Dashboard -> Project Settings -> Database -> Connection string -> URI) +# STAGING_DB_URL Same, for the staging project +# LOCAL_DB_URL Defaults to the Supabase CLI local DB if unset +# +# Skip auth syncing with: SYNC_AUTH=0 pnpm run db:sync:staging +# +set -euo pipefail + +TARGET="${1:-}" +if [[ -z "$TARGET" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/.env.sync" +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +: "${PROD_DB_URL:?PROD_DB_URL is required (see scripts/.env.sync.example)}" +SYNC_AUTH="${SYNC_AUTH:-1}" + +case "$TARGET" in + staging) + : "${STAGING_DB_URL:?STAGING_DB_URL is required for staging sync}" + TARGET_URL="$STAGING_DB_URL" + ;; + local) + TARGET_URL="${LOCAL_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}" + ;; + *) + echo "Unknown target: $TARGET (expected: staging | local)" >&2 + exit 1 + ;; +esac + +if [[ "$TARGET_URL" == "$PROD_DB_URL" ]]; then + echo "Refusing to run: target URL equals PROD_DB_URL." >&2 + exit 1 +fi + +echo "About to OVERWRITE the public schema in:" +echo " $TARGET_URL" +if [[ "$SYNC_AUTH" == "1" ]]; then + echo "and upsert anonymized auth.users from prod (existing target users kept)." +fi +read -r -p "Type 'yes' to continue: " CONFIRM +if [[ "$CONFIRM" != "yes" ]]; then + echo "Aborted." + exit 1 +fi + +TMP_DIR="$(mktemp -d -t upline-sync.XXXXXX)" +trap 'rm -rf "$TMP_DIR"' EXIT + +if [[ "$SYNC_AUTH" == "1" ]]; then + echo "Syncing auth.users from prod (anonymized)…" + AUTH_CSV="$TMP_DIR/auth-users.csv" + + psql "$PROD_DB_URL" -v ON_ERROR_STOP=1 -c "\copy (SELECT id, email_confirmed_at, created_at, updated_at, aud, role FROM auth.users) TO '$AUTH_CSV' WITH (FORMAT csv)" + + psql "$TARGET_URL" -v ON_ERROR_STOP=1 <@example.test)" + echo " with no password — they exist for FK integrity, not for sign-in." + echo " Existing test accounts on the target were preserved." +fi diff --git a/src/integrations/supabase/client.ts b/src/integrations/supabase/client.ts index 5709f2d7..9a346a19 100644 --- a/src/integrations/supabase/client.ts +++ b/src/integrations/supabase/client.ts @@ -3,12 +3,16 @@ import { createClient } from "@supabase/supabase-js"; import type { Database } from "./types"; import { createSupabaseStorage } from "@/lib/crossDomainStorage"; -const SUPABASE_URL = - import.meta.env.VITE_SUPABASE_URL || - "https://qssmazlqrmxiudxckxvi.supabase.co"; -const SUPABASE_PUBLISHABLE_KEY = - import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFzc21hemxxcm14aXVkeGNreHZpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTAzOTk4NjUsImV4cCI6MjA2NTk3NTg2NX0.4bltEUMgtxiDIbZDB9NLLKmeEDARt3yLjAbnO02RD_M"; +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; +const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY; + +if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) { + throw new Error( + "Missing VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY. " + + "For local dev: copy .env.local.example to .env.local and run `supabase start`. " + + "For deploys: set them as environment variables in your hosting provider.", + ); +} export const supabase = createClient( SUPABASE_URL, diff --git a/supabase/config.toml b/supabase/config.toml index a5c3e455..36efcda0 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1,4 +1,16 @@ project_id = "qssmazlqrmxiudxckxvi" [functions.sync-artist-data] -verify_jwt = false \ No newline at end of file +verify_jwt = false + +[auth.email] +# Match prod: passwordless sign-in via magic link without a separate +# confirmation step. Toggled in the Supabase dashboard for remote projects +# (config.toml is only binding for `supabase start`). +enable_confirmations = false +# 6-digit OTP to match the email template copy. +otp_length = 6 + +[auth.email.template.magic_link] +subject = "Welcome to the Festival!" +content_path = "./supabase/templates/magic_link.html" \ No newline at end of file diff --git a/supabase/templates/magic_link.html b/supabase/templates/magic_link.html new file mode 100644 index 00000000..3a98bbb6 --- /dev/null +++ b/supabase/templates/magic_link.html @@ -0,0 +1,208 @@ + + + + + + Welcome to the Festival! + + + + + +