Compare commits
12 Commits
16c8edf7e9
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c6501b3ecd | |||
| edf3989ef2 | |||
| 1d4300d505 | |||
| 0db1609c89 | |||
| 91ba7b5c5c | |||
| d312783f3a | |||
| 1af47698a1 | |||
| 03bd0fbf1f | |||
| e28ebf5664 | |||
| 653dce747b | |||
| b46e00fefd | |||
| ceb061addf |
+74
-48
@@ -24,7 +24,10 @@ jobs:
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
run: |
|
||||
npm run migrate:one || echo "No migration script or migrations already applied"
|
||||
set -e
|
||||
node scripts/preflight-check.js
|
||||
npm run migrate:one
|
||||
node scripts/postflight-check.js
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
@@ -109,67 +112,90 @@ jobs:
|
||||
set -e
|
||||
APP_DIR=/home/tyler/route-commerce
|
||||
|
||||
# Setup SSH key
|
||||
# Setup SSH key - write raw (no printf which can corrupt multi-line keys)
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SERVER_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
# Verify key was written correctly
|
||||
if ! grep -q "PRIVATE KEY" ~/.ssh/id_ed25519; then
|
||||
echo "ERROR: SSH key not found or malformed. Check SERVER_SSH_KEY secret."
|
||||
cat ~/.ssh/id_ed25519 || echo "File is empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ssh-keyscan -H route.crispygoat.com >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
# Test SSH connection
|
||||
# Test SSH connection with verbose output for debugging
|
||||
echo "Testing SSH connection..."
|
||||
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "echo 'SSH OK' && hostname" || { echo "SSH FAILED"; exit 1; }
|
||||
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no -o LogLevel=VERBOSE tyler@route.crispygoat.com "echo 'SSH OK' && hostname" 2>&1 || { echo "SSH FAILED"; exit 1; }
|
||||
|
||||
# Write production env file on server
|
||||
ssh tyler@route.crispygoat.com "mkdir -p $APP_DIR && cat > $APP_DIR/.env.production" << 'ENVEOF'
|
||||
DATABASE_URL=$DATABASE_URL
|
||||
NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
NEON_AUTH_BASE_URL=$NEON_AUTH_BASE_URL
|
||||
NEON_AUTH_COOKIE_SECRET=$NEON_AUTH_COOKIE_SECRET
|
||||
AUTH_SECRET=$AUTH_SECRET
|
||||
AUTH_URL=$AUTH_URL
|
||||
NEXT_PUBLIC_AUTH_URL=$NEXT_PUBLIC_AUTH_URL
|
||||
GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID
|
||||
GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET
|
||||
ALLOW_DEV_LOGIN=$ALLOW_DEV_LOGIN
|
||||
ADMIN_ALLOWED_EMAILS=$ADMIN_ALLOWED_EMAILS
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY
|
||||
STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET
|
||||
STRIPE_PRICE_STARTER=$STRIPE_PRICE_STARTER
|
||||
STRIPE_PRICE_FARM=$STRIPE_PRICE_FARM
|
||||
STRIPE_PRICE_ENTERPRISE=$STRIPE_PRICE_ENTERPRISE
|
||||
STRIPE_PRICE_HARVEST_REACH=$STRIPE_PRICE_HARVEST_REACH
|
||||
STRIPE_PRICE_WHOLESALE_PORTAL=$STRIPE_PRICE_WHOLESALE_PORTAL
|
||||
STRIPE_PRICE_WATER_LOG=$STRIPE_PRICE_WATER_LOG
|
||||
STRIPE_PRICE_AI_TOOLS=$STRIPE_PRICE_AI_TOOLS
|
||||
STRIPE_PRICE_SQUARE_SYNC=$STRIPE_PRICE_SQUARE_SYNC
|
||||
STRIPE_PRICE_SMS_CAMPAIGNS=$STRIPE_PRICE_SMS_CAMPAIGNS
|
||||
RESEND_API_KEY=$RESEND_API_KEY
|
||||
FROM_EMAIL=$FROM_EMAIL
|
||||
MINIO_ENDPOINT=$MINIO_ENDPOINT
|
||||
MINIO_REGION=$MINIO_REGION
|
||||
MINIO_ACCESS_KEY=$MINIO_ACCESS_KEY
|
||||
MINIO_SECRET_KEY=$MINIO_SECRET_KEY
|
||||
MINIO_PUBLIC_URL=$MINIO_PUBLIC_URL
|
||||
MINIO_BUCKET_PRODUCTS=$MINIO_BUCKET_PRODUCTS
|
||||
MINIO_BUCKET_BRAND_LOGOS=$MINIO_BUCKET_BRAND_LOGOS
|
||||
MINIO_BUCKET_WATER_LOGS=$MINIO_BUCKET_WATER_LOGS
|
||||
MINIMAX_API_KEY=$MINIMAX_API_KEY
|
||||
MINIMAX_BASE_URL=$MINIMAX_BASE_URL
|
||||
CRON_SECRET=$CRON_SECRET
|
||||
ENVEOF
|
||||
# Create app dir on server
|
||||
ssh tyler@route.crispygoat.com "mkdir -p $APP_DIR/.next $APP_DIR/public"
|
||||
|
||||
# Write production env file
|
||||
ENV_FILE=$(mktemp)
|
||||
{
|
||||
printf 'DATABASE_URL=%s\n' "$DATABASE_URL"
|
||||
printf 'NEXT_PUBLIC_SITE_URL=%s\n' "$NEXT_PUBLIC_SITE_URL"
|
||||
printf 'NEON_AUTH_BASE_URL=%s\n' "$NEON_AUTH_BASE_URL"
|
||||
printf 'NEON_AUTH_COOKIE_SECRET=%s\n' "$NEON_AUTH_COOKIE_SECRET"
|
||||
printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET"
|
||||
printf 'AUTH_URL=%s\n' "$AUTH_URL"
|
||||
printf 'NEXT_PUBLIC_AUTH_URL=%s\n' "$NEXT_PUBLIC_AUTH_URL"
|
||||
printf 'GOOGLE_CLIENT_ID=%s\n' "$GOOGLE_CLIENT_ID"
|
||||
printf 'GOOGLE_CLIENT_SECRET=%s\n' "$GOOGLE_CLIENT_SECRET"
|
||||
printf 'ALLOW_DEV_LOGIN=%s\n' "$ALLOW_DEV_LOGIN"
|
||||
printf 'ADMIN_ALLOWED_EMAILS=%s\n' "$ADMIN_ALLOWED_EMAILS"
|
||||
printf 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=%s\n' "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY"
|
||||
printf 'STRIPE_SECRET_KEY=%s\n' "$STRIPE_SECRET_KEY"
|
||||
printf 'STRIPE_WEBHOOK_SECRET=%s\n' "$STRIPE_WEBHOOK_SECRET"
|
||||
printf 'STRIPE_PRICE_STARTER=%s\n' "$STRIPE_PRICE_STARTER"
|
||||
printf 'STRIPE_PRICE_FARM=%s\n' "$STRIPE_PRICE_FARM"
|
||||
printf 'STRIPE_PRICE_ENTERPRISE=%s\n' "$STRIPE_PRICE_ENTERPRISE"
|
||||
printf 'STRIPE_PRICE_HARVEST_REACH=%s\n' "$STRIPE_PRICE_HARVEST_REACH"
|
||||
printf 'STRIPE_PRICE_WHOLESALE_PORTAL=%s\n' "$STRIPE_PRICE_WHOLESALE_PORTAL"
|
||||
printf 'STRIPE_PRICE_WATER_LOG=%s\n' "$STRIPE_PRICE_WATER_LOG"
|
||||
printf 'STRIPE_PRICE_AI_TOOLS=%s\n' "$STRIPE_PRICE_AI_TOOLS"
|
||||
printf 'STRIPE_PRICE_SQUARE_SYNC=%s\n' "$STRIPE_PRICE_SQUARE_SYNC"
|
||||
printf 'STRIPE_PRICE_SMS_CAMPAIGNS=%s\n' "$STRIPE_PRICE_SMS_CAMPAIGNS"
|
||||
printf 'RESEND_API_KEY=%s\n' "$RESEND_API_KEY"
|
||||
printf 'FROM_EMAIL=%s\n' "$FROM_EMAIL"
|
||||
printf 'MINIO_ENDPOINT=%s\n' "$MINIO_ENDPOINT"
|
||||
printf 'MINIO_REGION=%s\n' "$MINIO_REGION"
|
||||
printf 'MINIO_ACCESS_KEY=%s\n' "$MINIO_ACCESS_KEY"
|
||||
printf 'MINIO_SECRET_KEY=%s\n' "$MINIO_SECRET_KEY"
|
||||
printf 'MINIO_PUBLIC_URL=%s\n' "$MINIO_PUBLIC_URL"
|
||||
printf 'MINIO_BUCKET_PRODUCTS=%s\n' "$MINIO_BUCKET_PRODUCTS"
|
||||
printf 'MINIO_BUCKET_BRAND_LOGOS=%s\n' "$MINIO_BUCKET_BRAND_LOGOS"
|
||||
printf 'MINIO_BUCKET_WATER_LOGS=%s\n' "$MINIO_BUCKET_WATER_LOGS"
|
||||
printf 'MINIMAX_API_KEY=%s\n' "$MINIMAX_API_KEY"
|
||||
printf 'MINIMAX_BASE_URL=%s\n' "$MINIMAX_BASE_URL"
|
||||
printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
|
||||
} > "$ENV_FILE"
|
||||
|
||||
# Upload env file and sync build output
|
||||
echo "Uploading env file..."
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env.production
|
||||
|
||||
# Sync build output to server using scp (no apt install needed)
|
||||
echo "Copying .next/..."
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r .next/* tyler@route.crispygoat.com:$APP_DIR/.next/
|
||||
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r .next tyler@route.crispygoat.com:$APP_DIR/
|
||||
echo "Copying public/..."
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r public/* tyler@route.crispygoat.com:$APP_DIR/public/
|
||||
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r public tyler@route.crispygoat.com:$APP_DIR/
|
||||
echo "Copying package.json..."
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no package.json tyler@route.crispygoat.com:$APP_DIR/
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no next.config.ts tyler@route.crispygoat.com:$APP_DIR/ 2>/dev/null || true
|
||||
|
||||
# Ship the migration runner + SQL so the server has a recovery path (scripts/ and db/migrations/ were previously omitted from the artifact).
|
||||
# This allows `node scripts/migrate.js` (after sourcing .env.production) to work directly on the target if needed for bootstrap or emergencies.
|
||||
# See docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md
|
||||
echo "Ensuring migration directories on server and copying runner + SQL..."
|
||||
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "mkdir -p $APP_DIR/scripts $APP_DIR/db"
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/migrate.js tyler@route.crispygoat.com:$APP_DIR/scripts/migrate.js 2>/dev/null || true
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r db/migrations tyler@route.crispygoat.com:$APP_DIR/db/ 2>/dev/null || true
|
||||
|
||||
# Install deps and restart on server
|
||||
echo "Installing deps and restarting PM2..."
|
||||
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "cd $APP_DIR && npm install --omit=dev && pm2 restart route-commerce || pm2 start npm --name route-commerce -- start -- -p 3100 && pm2 save"
|
||||
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && pm2 restart route-commerce || pm2 start npm --name route-commerce -- start -- -p 3100 && pm2 save && sleep 4 && curl -f -s http://localhost:3100/api/health/db-schema || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
|
||||
|
||||
echo "Deployed successfully"
|
||||
echo "Deployed successfully"
|
||||
@@ -101,6 +101,33 @@ The app connects to **Postgres directly** — no Supabase platform, JS client, o
|
||||
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names.
|
||||
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase.
|
||||
|
||||
#### First production deploy / new prod DB bootstrap (critical for admin access)
|
||||
|
||||
The `admin_users` + `admin_user_brands` tables (and the rest of the schema) come **only** from `db/migrations/0001_init.sql`.
|
||||
|
||||
If the prod `DATABASE_URL` has never had the migrations applied, `getAdminUser()` will fail with "relation \"admin_users\" does not exist", the layout will show "Access Denied", and even a signed-in Neon Auth user will be blocked.
|
||||
|
||||
**Correct bootstrap sequence (do this from a machine with the full source tree before the first push that exercises /admin):**
|
||||
|
||||
1. Ensure the Gitea secret `DATABASE_URL` points at the real prod Neon Postgres (the one with `neon_auth.user` already present).
|
||||
2. Sign in once at the live prod URL (`/login`) with the email you want as the first `platform_admin`. This creates the row in `neon_auth.user`.
|
||||
3. From your laptop (or any box with the checkout):
|
||||
|
||||
```bash
|
||||
# Paste the real prod connection string (get it from Gitea secrets or the target's .env.production)
|
||||
DATABASE_URL="postgresql://...prod-full-string..." node scripts/migrate.js
|
||||
|
||||
# Then provision (the script will link to the first brand it finds)
|
||||
DATABASE_URL="postgresql://...prod-full-string..." \
|
||||
npx tsx scripts/provision-admin.ts you@real.com platform_admin
|
||||
```
|
||||
|
||||
4. Push to main. The deploy workflow now has a hard gate (see `.gitea/workflows/deploy.yml` "Run migrations" + verification query for `admin_users`) and ships the migrate runner + SQL files, so future deploys and server-side recovery are protected.
|
||||
|
||||
See the full root-cause + plan: `docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md`
|
||||
|
||||
The old `|| echo` masking around `npm run migrate:one` has been removed; a missing critical table will now fail the CI job with a clear message.
|
||||
|
||||
#### SECURITY DEFINER RPCs + Brand Scoping
|
||||
|
||||
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass any future RLS. This means:
|
||||
|
||||
@@ -2,7 +2,35 @@
|
||||
|
||||
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
|
||||
|
||||
**Last updated:** 2026-06-06 (Supabase → Postgres pivot)
|
||||
**Last updated:** 2026-06 (migration reliability + Google sign-in work)
|
||||
|
||||
## 2026-06: CI migration failures on re-deploy (0001_init.sql "already exists")
|
||||
|
||||
Prod DATABASE_URL already had the schema from the first successful bootstrap.
|
||||
The deploy workflow runs `npm run migrate:one` on every push (after neon_auth preflight).
|
||||
`scripts/migrate.js` has `_migrations` tracking + skip, but the row for `0001_init.sql` was never recorded (the tracking logic landed after the initial apply, or an apply happened outside the runner).
|
||||
|
||||
`db/migrations/0001_init.sql` header *claimed* "CREATE TABLE IF NOT EXISTS" but the actual statements were plain `CREATE TABLE`, plain `CREATE INDEX`, and unguarded `CREATE TRIGGER`.
|
||||
|
||||
Result: every subsequent deploy hit `relation "admin_users" already exists` (and would have hit index/trigger dups too) inside the runner's BEGIN, causing ROLLBACK + failure of the whole "Run migrations" job.
|
||||
|
||||
### Fixes applied
|
||||
- Made `0001_init.sql` truly re-runnable:
|
||||
- All `CREATE TABLE` → `CREATE TABLE IF NOT EXISTS`
|
||||
- All `CREATE INDEX` / `CREATE UNIQUE INDEX` → `... IF NOT EXISTS`
|
||||
- Every `CREATE TRIGGER` wrapped in a `DO $$ IF NOT EXISTS (pg_trigger check) THEN CREATE TRIGGER ... END IF; $$` guard
|
||||
- Removed the file-level `BEGIN; ... COMMIT;` (the runner owns the tx; this also prevents inner-COMMIT from ending the runner tx early).
|
||||
- `0002_admin_password.sql` had its tx wrapper removed for consistency (its ALTER was already `IF NOT EXISTS`).
|
||||
- Hardened `scripts/migrate.js`:
|
||||
- Added `ensureTracked()` repair: for 0001/0002, if the core objects (admin_users table, or the password_hash column) already exist in the target DB but the tracking row is absent, we INSERT the row (ON CONFLICT DO NOTHING) and skip the file. Logs "repaired tracking".
|
||||
- Hardened `.gitea/workflows/deploy.yml` "Run migrations" step:
|
||||
- Added an inline pre-repair node snippet (same idea) right before `npm run migrate:one`. This protects even if an older runner is checked out.
|
||||
- The existing neon_auth preflight + post `admin_users` verification remain as the hard gate.
|
||||
- Updated header comments and docs in the files.
|
||||
|
||||
After this, `npm run migrate` / deploys on an already-initialized DB will log the "repaired" or "already applied" lines for 0001 and proceed cleanly. The shipped `scripts/migrate.js` + `db/migrations/` on the target server also benefit for emergency recovery runs.
|
||||
|
||||
See also the plan doc referenced in deploy.yml for the broader reliability work.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+389
-142
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,11 @@
|
||||
-- `src/lib/auth.ts` — it queries this column and runs `verifyPassword`
|
||||
-- (see `src/lib/passwords.ts`) before returning the user.
|
||||
|
||||
BEGIN;
|
||||
-- Transaction is managed by the migrate runner (scripts/migrate.js).
|
||||
-- File kept small and re-runnable via IF NOT EXISTS on the ALTER.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS password_hash TEXT;
|
||||
|
||||
-- Update the updated_at trigger tracking — no new triggers needed since
|
||||
-- `users` already has `set_updated_at` from migration 0001.
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# Production DB Schema Migration Reliability Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:systematic-debugging (completed root cause), superpowers:writing-plans (this), superpowers:test-driven-development where code changes have tests, superpowers:verification-before-completion, and superpowers:executing-plans or subagent-driven-development to implement task-by-task. Steps use checkbox syntax.
|
||||
|
||||
**Goal:** Ensure that every production deploy (push to main) results in the full schema from `db/migrations/0001_init.sql` (including the `admin_users` and `admin_user_brands` tables required by `getAdminUser()`) being present in the `DATABASE_URL` that the running app connects to via its pool / drizzle client. Eliminate "relation does not exist" errors and the resulting Access Denied screen for properly provisioned Neon Auth users.
|
||||
|
||||
**Architecture:**
|
||||
- Make the CI "Run migrations" step a hard gate (fatal on failure, plus explicit post-migrate verification that critical tables exist).
|
||||
- Ship the minimal migration runner + SQL files as part of the deploy artifact so the target server has a recovery path.
|
||||
- Add a lightweight post-deploy / startup verification in the app or deploy script (fail fast with clear message instead of silent 500s on first admin request).
|
||||
- Keep the "migrate from full source locally" path working for initial prod DB bootstrap and emergencies.
|
||||
- Do not change the core migration logic or 0001_init.sql in this plan (that would be a separate architectural change if the double-BEGIN wrapping proves fragile).
|
||||
|
||||
**Tech Stack:** Gitea Actions (YAML), Node 22 + pg + drizzle on target, Next.js standalone output, pm2 on Ubuntu server, Neon Postgres (with neon_auth schema).
|
||||
|
||||
**Root Cause (from systematic-debugging Phase 1):** The prod runtime DB lacked the `admin_users` table because (1) the migration step in `.gitea/workflows/deploy.yml` used `|| echo` making any failure (connection, SQL error in the huge 0001 file, FK to neon_auth.user, tx nesting from the file's BEGIN + script's BEGIN) non-fatal, (2) only `.next/`, `public/`, `package.json` (and optional next.config) are scp'd — `scripts/migrate.js` and `db/migrations/` are never on the server, (3) no verification after "migrate" or at app startup that the tables the admin permission layer depends on actually exist, (4) the `.env.production` written from the same secret as the CI migrate step was used, but the apply didn't happen or was skipped due to _migrations state or partial rollback.
|
||||
|
||||
**Evidence Gathered:**
|
||||
- deploy.yml: Run migrations step, limited scp, .env.production printf, pm2 "npm start".
|
||||
- scripts/migrate.js: dotenv .env.local + env override, _migrations tracking, per-file client.query(sql) inside script tx, re-throw on error.
|
||||
- db/migrations/0001_init.sql: explicit CREATE TABLE admin_users (with FK to neon_auth.user), admin_user_brands, brands; file starts with BEGIN;.
|
||||
- db/client.ts + src/lib/admin-permissions.ts: withPlatformAdmin → drizzle select on adminUsers from schema (the exact query that 42P01s).
|
||||
- next.config.ts: output: 'standalone' (explains pm2 warning).
|
||||
- Runtime logs: the repeated "Database query failed" + "relation does not exist", app starts fine.
|
||||
- Git history: recent deploy "fixes" focused on SSH/env writing, not migration reliability.
|
||||
|
||||
**Files to touch (decomposition by responsibility):**
|
||||
- `.gitea/workflows/deploy.yml` (CI pipeline gates + artifact contents)
|
||||
- `scripts/migrate.js` (minor hardening if needed for verification hook)
|
||||
- `src/app/api/health/route.ts` or similar (new, for startup/schema check — or add to existing)
|
||||
- `CLAUDE.md` + `PRODUCTION_DEPLOYMENT_CHECKLIST.md` (docs)
|
||||
- Possibly a small `scripts/verify-prod-schema.js` helper
|
||||
|
||||
---
|
||||
### Task 1: Make CI migration step a hard failure + add explicit verification for admin_users
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitea/workflows/deploy.yml:23-27` (the Run migrations step and surrounding)
|
||||
|
||||
- [ ] **Step 1.1:** Replace the non-fatal migration line with a strict block that fails the job if migrate fails or the critical table is missing after.
|
||||
|
||||
```yaml
|
||||
- name: Run migrations
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
run: |
|
||||
set -e
|
||||
echo "=== Running migrations against prod DB ==="
|
||||
npm run migrate:one
|
||||
echo "=== Verifying critical schema (admin_users) ==="
|
||||
node -e '
|
||||
const {Client} = require("pg");
|
||||
const c = new Client({connectionString: process.env.DATABASE_URL});
|
||||
c.connect().then(() => c.query("SELECT 1 FROM admin_users LIMIT 1")).then(() => {
|
||||
console.log("✓ admin_users table exists");
|
||||
return c.end();
|
||||
}).then(() => process.exit(0)).catch(e => {
|
||||
console.error("✗ admin_users missing or inaccessible:", e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
'
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2:** Run a local simulation or note that the Gitea runner will now fail the whole deploy if the secret DB is missing the table (good — forces the bootstrap to happen before code that depends on it ships).
|
||||
|
||||
- [ ] **Step 1.3:** Commit the yml change with message referencing the root cause (missing table in prod due to masked migration).
|
||||
|
||||
### Task 2: Ship migration capability in the deploy artifact so server has a recovery path
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitea/workflows/deploy.yml` in the "Deploy" step (the scp and ssh sections)
|
||||
|
||||
- [ ] **Step 2.1:** Add scp for the migration assets (after the existing public/.next scp):
|
||||
|
||||
```bash
|
||||
echo "Copying migration runner and SQL..."
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r scripts/migrate.js tyler@...:$APP_DIR/scripts/ || true
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r db/migrations tyler@...:$APP_DIR/db/ || true
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2:** Update the server ssh install/restart line to also ensure the scripts dir has the right perms if needed, but mainly document that now `node scripts/migrate.js` will work on the server with the .env.production.
|
||||
|
||||
- [ ] **Step 2.3:** In the same Deploy step, after writing .env.production and before or after the pm2 restart, optionally run the migrate on the server as a belt-and-suspenders (using the just-written .env):
|
||||
|
||||
```bash
|
||||
ssh ... "cd $APP_DIR && source .env.production 2>/dev/null || export \$(grep DATABASE_URL .env.production); node scripts/migrate.js || echo 'migrate on server completed or not needed'"
|
||||
```
|
||||
|
||||
(Keep it non-fatal on server for now; the CI gate is the hard one.)
|
||||
|
||||
- [ ] **Step 2.4:** Test the scp paths in a dry-run or note the change.
|
||||
|
||||
### Task 3: Add a minimal runtime / startup guard (fail fast with clear message)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/api/health/db-schema/route.ts` (or add to an existing health if present)
|
||||
- Or simpler: in the admin layout or a top level, but a dedicated health is better for PM2/docker.
|
||||
|
||||
- [ ] **Step 3.1:** Create a tiny health endpoint that does the same check the CI verification does (SELECT 1 FROM admin_users) using the existing pool or withDb, returns 200 or 503 with message "Schema not applied - run migrations".
|
||||
|
||||
- [ ] **Step 3.2:** Wire it so the deploy can curl it after restart as a final gate (in the workflow ssh step).
|
||||
|
||||
- [ ] **Step 3.3:** (Optional but recommended per defense-in-depth) Call a similar check early in getAdminUser or the admin layout and log a very loud message + return a better error than generic "does not have admin access" when the table is literally missing.
|
||||
|
||||
### Task 4: Update documentation and bootstrap instructions (so humans know the right sequence)
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (the Commands and Important File Locations + Gotchas sections)
|
||||
- Modify or create: `PRODUCTION_DEPLOYMENT_CHECKLIST.md` or a new `docs/PROD_BOOTSTRAP.md`
|
||||
|
||||
- [ ] **Step 4.1:** In CLAUDE.md under "Commands" and "Adding a New Brand" / auth section, add a "First production deploy / new prod DB bootstrap" subsection:
|
||||
|
||||
1. Ensure the Neon project has neon_auth enabled and the DATABASE_URL secret in Gitea points to it.
|
||||
2. (Before first code push that depends on admin) Locally or in a throwaway runner: `DATABASE_URL=prod... node scripts/migrate.js`
|
||||
3. Then `DATABASE_URL=prod... npx tsx scripts/provision-admin.ts you@real.com platform_admin` (after signing in on the prod URL).
|
||||
4. Push; the CI gate + shipped runner will keep it healthy on future deploys.
|
||||
5. If you ever see "relation admin_users does not exist" in prod logs, the DB the app is talking to is not the one that had migrate run.
|
||||
|
||||
- [ ] **Step 4.2:** Add a note about the `|| echo` anti-pattern that was removed and why the new verification step exists.
|
||||
|
||||
- [ ] **Step 4.3:** Mention the standalone vs npm start issue (already in logs) and that the start command on server should eventually be updated to `node .next/standalone/server.js -p 3100` (can be a follow-up task).
|
||||
|
||||
### Task 5: Verification before claiming success (use the dedicated skill)
|
||||
|
||||
**Files:** (none new, just process)
|
||||
|
||||
- [ ] **Step 5.1:** Before merging the plan changes, use `superpowers:verification-before-completion` checklist: the change makes a fresh DB get the table, an "already applied" DB is a no-op, a deploy with missing table now fails the job early with clear output, a manual server migrate works because the files are there, the runtime health returns 200 when table present.
|
||||
|
||||
- [ ] **Step 5.2:** After the PR is on a branch, trigger a deploy to a staging or the real prod (with a test DB first if possible), capture the CI log showing the new verification passing, and the app logs showing no more "Database query failed" on /admin.
|
||||
|
||||
- [ ] **Step 5.3:** Run the provision script as the final user-visible test; confirm the Access Denied with email message is gone and the platform_admin can see the UI.
|
||||
|
||||
- [ ] **Step 5.4:** Document the before/after in the plan or a memory file.
|
||||
|
||||
### Task 6: (Stretch / follow-up) Improve the migrate script's resilience for huge init files (if the double tx ever bites again)
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/migrate.js`
|
||||
|
||||
- [ ] Only if during verification the 0001 apply is flaky: change the per-file execution to not wrap the file's own BEGIN/COMMIT, or use a separate connection, or exec `psql -f` (but keep node/pg for consistency). Add a comment explaining the previous fragility.
|
||||
|
||||
**Rollback / emergency:** If a deploy breaks because of this, the server now has the scripts + db/migrations copied, so SSH + `source .env.production; node scripts/migrate.js` is the recovery (exactly what the user was trying to do manually).
|
||||
|
||||
**Success criteria:**
|
||||
- A brand new prod DB + push to main results in a green deploy + working /admin after provision.
|
||||
- The error "relation \"admin_users\" does not exist" no longer appears in prod pm2 logs for normal admin flows.
|
||||
- The pipeline fails loudly (with the table name in the error) instead of shipping a broken app that only shows "Access Denied".
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "route-commerce-platform",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
|
||||
|
||||
+39
-6
@@ -1,14 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Apply Postgres migrations from `db/migrations/*.sql` in lexical order.
|
||||
* Wraps the whole thing in a transaction; tracks applied files in
|
||||
* `_migrations` so re-runs are safe.
|
||||
* Wraps each new file in a transaction; tracks applied files in
|
||||
* `_migrations` so re-runs are safe and idempotent.
|
||||
*
|
||||
* The 0001_init.sql (and 0002) are now fully re-runnable (IF NOT EXISTS +
|
||||
* trigger guards) + this script has repair logic for DBs that were
|
||||
* initialized before tracking was introduced.
|
||||
*
|
||||
* Usage:
|
||||
* npm run db:migrate
|
||||
*
|
||||
* Replaces the old `supabase/push-migrations.js` — that script was
|
||||
* hardcoded to a Supabase URL. This one reads `DATABASE_URL` directly.
|
||||
* npm run migrate
|
||||
* npm run migrate:one # same as above (applies all pending)
|
||||
*/
|
||||
require("dotenv").config({ path: ".env.local" });
|
||||
|
||||
@@ -51,6 +53,37 @@ async function main() {
|
||||
);
|
||||
const appliedSet = new Set(applied.map((r) => r.filename));
|
||||
|
||||
// Repair for historical DBs (applied before _migrations tracking existed,
|
||||
// or via direct psql / earlier tooling). If the core objects are present
|
||||
// we record the filename so future deploys (and server-side recovery runs)
|
||||
// treat 0001/0002 as done without re-executing the large init script.
|
||||
async function ensureTracked(filename, existenceCheckSql) {
|
||||
if (appliedSet.has(filename)) return;
|
||||
try {
|
||||
const { rows } = await client.query(existenceCheckSql);
|
||||
if (rows.length > 0) {
|
||||
await client.query(
|
||||
`INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT (filename) DO NOTHING`,
|
||||
[filename]
|
||||
);
|
||||
console.log(`✓ ${filename} (objects present in DB; repaired tracking)`);
|
||||
appliedSet.add(filename);
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal: the check may fail on a brand-new DB or with limited perms.
|
||||
// We'll let the normal apply path handle it.
|
||||
}
|
||||
}
|
||||
|
||||
await ensureTracked(
|
||||
"0001_init.sql",
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name = 'admin_users' LIMIT 1"
|
||||
);
|
||||
await ensureTracked(
|
||||
"0002_admin_password.sql",
|
||||
"SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'password_hash' LIMIT 1"
|
||||
);
|
||||
|
||||
let appliedNow = 0;
|
||||
for (const file of files) {
|
||||
if (appliedSet.has(file)) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-migration verification.
|
||||
* Called by .gitea/workflows/deploy.yml after npm run migrate:one.
|
||||
* Confirms critical table admin_users is queryable.
|
||||
*/
|
||||
const { Client } = require("pg");
|
||||
|
||||
async function main() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("No DATABASE_URL");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const c = new Client({ connectionString: url });
|
||||
await c.connect();
|
||||
|
||||
try {
|
||||
console.log("=== Post-migration verification: critical table admin_users must exist ===");
|
||||
await c.query("SELECT 1 FROM admin_users LIMIT 1");
|
||||
console.log("✓ admin_users table exists and is queryable");
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error("✗ FATAL: admin_users relation missing after migrate:", e.message);
|
||||
console.error("The deploy cannot continue. The secret DATABASE_URL must have had db/migrations/0001_init.sql applied successfully.");
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Preflight check + migration repair runner.
|
||||
* Called by .gitea/workflows/deploy.yml before running migrations.
|
||||
*
|
||||
* 1. Verify neon_auth schema exists (prerequisite for 0001_init.sql FKs)
|
||||
* 2. Ensure 0001_init.sql is tracked in _migrations if admin_users already exists
|
||||
* (repair for DBs that had 0001_init.sql applied before _migrations was added)
|
||||
*/
|
||||
const { Client } = require("pg");
|
||||
|
||||
async function main() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("No DATABASE_URL");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const c = new Client({ connectionString: url });
|
||||
await c.connect();
|
||||
|
||||
try {
|
||||
// 1. Pre-flight: check neon_auth schema
|
||||
console.log("=== Pre-flight: checking for neon_auth schema (required by 0001_init.sql for FKs to neon_auth.user and related RPCs) ===");
|
||||
const schemaRes = await c.query(
|
||||
"SELECT 1 FROM information_schema.schemata WHERE schema_name = 'neon_auth'"
|
||||
);
|
||||
if (schemaRes.rows.length === 0) {
|
||||
console.error("✗ FATAL: neon_auth schema does not exist in the target database.");
|
||||
console.error("Enable Neon Auth on the Neon project/branch that this DATABASE_URL points to first.");
|
||||
console.error("Run: neonctl neon-auth (or equivalent) against the correct Neon branch.");
|
||||
console.error("The neon_auth schema is created by Neon Auth and is a prerequisite for 0001_init.sql.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("✓ neon_auth schema exists");
|
||||
|
||||
// 2. Pre-repair: ensure 0001_init.sql is tracked if admin_users already exists
|
||||
// This handles DBs where 0001_init.sql was applied before _migrations tracking existed.
|
||||
console.log("=== Pre-repair: checking _migrations tracking (best-effort) ===");
|
||||
try {
|
||||
const tableRes = await c.query(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name = 'admin_users' LIMIT 1"
|
||||
);
|
||||
if (tableRes.rows.length > 0) {
|
||||
await c.query(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO _migrations (filename)
|
||||
VALUES ('0001_init.sql')
|
||||
ON CONFLICT (filename) DO NOTHING;
|
||||
`);
|
||||
console.log("✓ 0001_init.sql tracking repaired (admin_users already present)");
|
||||
}
|
||||
} catch (e) {
|
||||
// best-effort
|
||||
console.log("(pre-repair skipped: " + e.message + ")");
|
||||
}
|
||||
|
||||
console.log("Preflight complete. Proceeding to migrations...");
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error("Preflight failed:", e.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
+17
-10
@@ -8,16 +8,23 @@ import pg from "pg";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// Load .env.local manually
|
||||
const envPath = path.join(process.cwd(), ".env.local");
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, "utf-8");
|
||||
for (const line of envContent.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const [key, ...valueParts] = trimmed.split("=");
|
||||
process.env[key.trim()] = valueParts.join("=").trim();
|
||||
// Load .env.local (or .env.production) manually so the script works in prod bootstrap
|
||||
const envFiles = [".env.local", ".env.production"];
|
||||
for (const f of envFiles) {
|
||||
const envPath = path.join(process.cwd(), f);
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, "utf-8");
|
||||
for (const line of envContent.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const [key, ...valueParts] = trimmed.split("=");
|
||||
const k = key.trim();
|
||||
if (!process.env[k]) {
|
||||
process.env[k] = valueParts.join("=").trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[provision] loaded ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +113,7 @@ async function main() {
|
||||
}
|
||||
|
||||
console.log(`\n✅ ${email} is now provisioned as ${role}!`);
|
||||
console.log(` They can access the admin at http://localhost:4000/admin`);
|
||||
console.log(` They can access the admin at your production URL /admin (sign in first if needed).`);
|
||||
|
||||
} finally {
|
||||
await pool.end();
|
||||
|
||||
@@ -211,6 +211,25 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
|
||||
);
|
||||
if (!rows[0]) return { user: null, error: "Insert returned no row" };
|
||||
|
||||
const newAdminId = String(rows[0].id);
|
||||
|
||||
// Ensure the admin_user_brands link exists for brand-scoped roles.
|
||||
// (Platform admins created without a chosen brand may have 0 links and still
|
||||
// get access via role; getAdminUser allows this.)
|
||||
if (input.brand_id) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (admin_user_id, brand_id) DO NOTHING`,
|
||||
[newAdminId, input.brand_id],
|
||||
);
|
||||
} catch (linkErr) {
|
||||
console.error("[createAdminUser] Failed to create admin_user_brands link:", linkErr);
|
||||
// Non-fatal — the user row exists; a platform admin can link manually.
|
||||
}
|
||||
}
|
||||
|
||||
await sendWelcomeEmailSafe({
|
||||
to: input.email,
|
||||
name: input.display_name ?? input.email.split("@")[0],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { listBrandsForAdmin } from "@/actions/brands";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import "@/styles/admin-design-system.css";
|
||||
import { ToastProvider } from "@/components/admin/Toast";
|
||||
import { ToastContainer } from "@/components/admin/ToastContainer";
|
||||
@@ -47,13 +48,25 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
);
|
||||
}
|
||||
|
||||
// Not authenticated
|
||||
// Not authenticated / not provisioned
|
||||
if (!adminUser) {
|
||||
// Best-effort: surface the Neon Auth identity so the user (or support) knows
|
||||
// which account was checked. getAdminUser already logged details.
|
||||
let attemptedEmail: string | null = null;
|
||||
try {
|
||||
const { data: session } = await getSession();
|
||||
attemptedEmail = session?.user?.email ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const message = attemptedEmail
|
||||
? `Your account (${attemptedEmail}) does not have admin access. Contact a platform administrator to be provisioned.`
|
||||
: "Your account does not have admin access.";
|
||||
return (
|
||||
<ToastProviderWrapper>
|
||||
<AdminSidebar userRole={null} />
|
||||
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<AdminAccessDenied message="Your account does not have admin access." />
|
||||
<AdminAccessDenied message={message} />
|
||||
</div>
|
||||
</ToastProviderWrapper>
|
||||
);
|
||||
|
||||
+10
-9
@@ -1,10 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||
import DashboardClient from "@/components/admin/DashboardClient";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
@@ -31,17 +31,18 @@ export default async function AdminPage() {
|
||||
// so a transient DB/network failure can't crash the whole admin page.
|
||||
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
|
||||
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
||||
// Direct pg query (the supabase shim returns empty results).
|
||||
// This ensures a platform_admin sees real dashboard stats even on first login
|
||||
// before they have chosen an active brand.
|
||||
try {
|
||||
const { data: firstBrand } = await supabase
|
||||
.from("brands")
|
||||
.select("id")
|
||||
.limit(1)
|
||||
.single();
|
||||
if (firstBrand?.id) {
|
||||
dashboardBrandId = String(firstBrand.id);
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
`SELECT id FROM brands ORDER BY created_at ASC LIMIT 1`
|
||||
);
|
||||
if (rows[0]?.id) {
|
||||
dashboardBrandId = rows[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[admin/page] supabase brands lookup failed:", err);
|
||||
console.error("[admin/page] brands lookup failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Lightweight health check for the critical schema tables required by the
|
||||
* admin permission system (admin_users).
|
||||
*
|
||||
* Used by the deploy workflow as a final post-start gate.
|
||||
* Returns 200/JSON on success, 503 on missing table or connection error.
|
||||
*
|
||||
* This provides the "fail fast with clear message" instead of the previous
|
||||
* silent "Access Denied" that only appeared on first admin request.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Same check used in the CI verification step and the original error path.
|
||||
await pool.query("SELECT 1 FROM admin_users LIMIT 1");
|
||||
return NextResponse.json(
|
||||
{ status: "ok", message: "admin_users table present" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
message: err?.message ?? "Database schema check failed",
|
||||
hint: "Run migrations against the DATABASE_URL (see docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md)",
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,16 @@ export default function AdminAccessDenied({
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-stone-500">{message}</p>
|
||||
<Link
|
||||
href="/admin"
|
||||
href="/login"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium text-white transition-all shadow-sm"
|
||||
>
|
||||
Back to Admin
|
||||
Go to Login
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-3 block text-sm text-stone-500 hover:text-stone-700 underline-offset-2 hover:underline"
|
||||
>
|
||||
Return to homepage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,13 +18,15 @@ import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permission
|
||||
* Returns `null` if:
|
||||
* - No Neon Auth session (caller not signed in)
|
||||
* - The session email doesn't match any `admin_users.email`
|
||||
* - The user has no `admin_user_brands` row (not provisioned yet)
|
||||
* - The user is a brand-scoped role (brand_admin / store_employee) and has no
|
||||
* rows in `admin_user_brands` (not provisioned for any brand yet)
|
||||
*
|
||||
* Provisioning: an admin must run
|
||||
* INSERT INTO admin_users (email, ...) VALUES (...)
|
||||
* INSERT INTO admin_user_brands (admin_user_id, brand_id, role) VALUES (...)
|
||||
* to grant a signed-in user admin access. Until provisioned, the
|
||||
* layout shows "Access Denied" — correct behavior.
|
||||
* Platform admins may be provisioned with zero brand links and still receive
|
||||
* access (they see all brands). Brand-scoped admins require >= 1 link row.
|
||||
*
|
||||
* Provisioning: an admin must ensure rows exist in both tables for the user's
|
||||
* email (matched from their Neon Auth session). Until provisioned, the layout
|
||||
* shows "Access Denied" — correct behavior.
|
||||
*/
|
||||
export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
// Check for dev_session cookie in development mode
|
||||
@@ -39,53 +41,75 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
let sessionEmail: string | null = null;
|
||||
try {
|
||||
const { data: session } = await getSession();
|
||||
console.log("[admin-permissions] Full session:", JSON.stringify(session));
|
||||
sessionEmail = session?.user?.email ?? null;
|
||||
console.log("[admin-permissions] Session email:", sessionEmail);
|
||||
} catch (err) {
|
||||
console.error("[admin-permissions] getSession() failed:", err);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!sessionEmail) return null;
|
||||
if (!sessionEmail) {
|
||||
console.log("[admin-permissions] No session email - returning null");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await withPlatformAdmin(async (db) => {
|
||||
const userRows = await db
|
||||
.select()
|
||||
.from(adminUsers)
|
||||
.where(eq(adminUsers.email, sessionEmail.toLowerCase()))
|
||||
.limit(1);
|
||||
const user = userRows[0];
|
||||
if (!user) return null;
|
||||
try {
|
||||
return await withPlatformAdmin(async (db) => {
|
||||
console.log("[admin-permissions] Looking for user with email:", sessionEmail.toLowerCase());
|
||||
const userRows = await db
|
||||
.select()
|
||||
.from(adminUsers)
|
||||
.where(eq(adminUsers.email, sessionEmail.toLowerCase()))
|
||||
.limit(1);
|
||||
console.log("[admin-permissions] User rows found:", userRows.length, userRows);
|
||||
const user = userRows[0];
|
||||
if (!user) {
|
||||
console.log("[admin-permissions] User not found in admin_users");
|
||||
return null;
|
||||
}
|
||||
|
||||
const membershipRows = await db
|
||||
.select({
|
||||
brandId: adminUserBrands.brandId,
|
||||
brandName: brands.name,
|
||||
brandSlug: brands.slug,
|
||||
role: adminUserBrands.adminUserId,
|
||||
})
|
||||
.from(adminUserBrands)
|
||||
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
|
||||
.where(eq(adminUserBrands.adminUserId, user.id))
|
||||
.limit(1);
|
||||
const role = (user.role as AdminRole) || "brand_admin";
|
||||
|
||||
if (membershipRows.length === 0) {
|
||||
// Signed in but not provisioned for any brand.
|
||||
return null;
|
||||
}
|
||||
// Load all brand memberships for this admin (supports multi-brand admins).
|
||||
// The redundant adminUsers join was removed; role lives on admin_users.
|
||||
const membershipRows = await db
|
||||
.select({
|
||||
brandId: adminUserBrands.brandId,
|
||||
brandName: brands.name,
|
||||
brandSlug: brands.slug,
|
||||
})
|
||||
.from(adminUserBrands)
|
||||
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
|
||||
.where(eq(adminUserBrands.adminUserId, user.id));
|
||||
|
||||
const m = membershipRows[0];
|
||||
const role = user.role as AdminRole;
|
||||
return buildAdminUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.name,
|
||||
brandId: m.brandId,
|
||||
brandName: m.brandName,
|
||||
brandSlug: m.brandSlug,
|
||||
role,
|
||||
active: true,
|
||||
console.log("[admin-permissions] Membership rows found:", membershipRows.length, membershipRows);
|
||||
|
||||
// Brand-scoped roles (brand_admin, store_employee) require at least one brand link.
|
||||
// Platform admins may have zero or more explicit links; they get cross-brand access via role.
|
||||
if (membershipRows.length === 0 && role !== "platform_admin") {
|
||||
// Signed in but not provisioned for any brand.
|
||||
console.log("[admin-permissions] No brand memberships for non-platform user — denying");
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = membershipRows[0] ?? null;
|
||||
return buildAdminUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.name,
|
||||
brandId: first?.brandId ?? null,
|
||||
brandName: first?.brandName ?? null,
|
||||
brandSlug: first?.brandSlug ?? null,
|
||||
role,
|
||||
active: true,
|
||||
brandIds: membershipRows.map((m) => m.brandId),
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[admin-permissions] Database query failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,19 +170,23 @@ function buildAdminUser(input: {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
brandSlug: string;
|
||||
brandId: string | null;
|
||||
brandName: string | null;
|
||||
brandSlug: string | null;
|
||||
role: AdminRole;
|
||||
active: boolean;
|
||||
brandIds?: string[];
|
||||
}): AdminUser {
|
||||
const brandIds = input.brandIds && input.brandIds.length > 0
|
||||
? input.brandIds
|
||||
: (input.brandId ? [input.brandId] : []);
|
||||
return {
|
||||
id: input.id,
|
||||
user_id: input.id,
|
||||
email: input.email,
|
||||
display_name: input.displayName,
|
||||
brand_id: input.brandId,
|
||||
brand_ids: [input.brandId],
|
||||
brand_ids: brandIds,
|
||||
brand_slug: input.brandSlug,
|
||||
role: input.role,
|
||||
active: input.active,
|
||||
|
||||
@@ -6,58 +6,68 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Use vi.hoisted to ensure mocks are available when vi.mock runs
|
||||
const { mockDb, mockWithPlatformAdmin, mockGetSession, mockCookies } = vi.hoisted(() => {
|
||||
const mockDb = {
|
||||
select: vi.fn(),
|
||||
};
|
||||
return {
|
||||
mockDb,
|
||||
mockWithPlatformAdmin: vi.fn(async (fn: (db: typeof mockDb) => Promise<unknown>) => {
|
||||
return fn(mockDb);
|
||||
}),
|
||||
mockGetSession: vi.fn(),
|
||||
mockCookies: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Stub the server-only guard so the module can be imported under vitest.
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
// Mock the Drizzle client wrapper so we don't need a real DB.
|
||||
type MockFn = (arg: unknown) => Promise<unknown>;
|
||||
const mockSelect = vi.fn();
|
||||
const mockWithPlatformAdmin = vi.fn(async (fn: MockFn) => fn({ select: mockSelect }));
|
||||
// Mock the Drizzle client wrapper
|
||||
vi.mock("@/db/client", () => ({
|
||||
withPlatformAdmin: (fn: MockFn) => mockWithPlatformAdmin(fn),
|
||||
withPlatformAdmin: mockWithPlatformAdmin,
|
||||
}));
|
||||
|
||||
// Mock the getSession() function. The default mock returns null (no session).
|
||||
const getSessionMock = vi.fn();
|
||||
// Mock the getSession() function
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getSession: getSessionMock,
|
||||
getSession: mockGetSession,
|
||||
}));
|
||||
|
||||
// Mock cookies() so we don't read a real cookie store.
|
||||
const cookieStoreGet = vi.fn();
|
||||
// Mock cookies() so we don't read a real cookie store
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: () =>
|
||||
Promise.resolve({
|
||||
get: (name: string) => cookieStoreGet(name),
|
||||
}),
|
||||
cookies: mockCookies,
|
||||
}));
|
||||
|
||||
import { getAdminUser, buildDevAdmin, permissionsForRole } from "@/lib/admin-permissions";
|
||||
|
||||
const cookieStore = { get: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
mockSelect.mockReset();
|
||||
getSessionMock.mockReset();
|
||||
cookieStoreGet.mockReset();
|
||||
mockDb.select.mockReset();
|
||||
mockWithPlatformAdmin.mockReset();
|
||||
mockGetSession.mockReset();
|
||||
mockCookies.mockImplementation(() => Promise.resolve(cookieStore));
|
||||
cookieStore.get.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe("getAdminUser()", () => {
|
||||
it("returns null when there is no Neon Auth session", async () => {
|
||||
getSessionMock.mockResolvedValue({ data: null });
|
||||
cookieStoreGet.mockReturnValue(undefined);
|
||||
mockGetSession.mockResolvedValue({ data: null });
|
||||
const u = await getAdminUser();
|
||||
expect(u).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the session has no email", async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { user: { name: "no-email" } } });
|
||||
mockGetSession.mockResolvedValue({ data: { user: { name: "no-email" } } });
|
||||
const u = await getAdminUser();
|
||||
expect(u).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the email is not in the users table", async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { user: { email: "unknown@example.com" } } });
|
||||
mockGetSession.mockResolvedValue({ data: { user: { email: "unknown@example.com" } } });
|
||||
// First select: users. Returns empty.
|
||||
mockSelect.mockReturnValueOnce({
|
||||
mockDb.select.mockReturnValueOnce({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [],
|
||||
@@ -69,9 +79,9 @@ describe("getAdminUser()", () => {
|
||||
});
|
||||
|
||||
it("returns null when the user exists but has no admin_user_brands row", async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { user: { email: "no-brand@example.com" } } });
|
||||
mockGetSession.mockResolvedValue({ data: { user: { email: "no-brand@example.com" } } });
|
||||
// First select: users — returns the user
|
||||
mockSelect
|
||||
mockDb.select
|
||||
.mockReturnValueOnce({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
@@ -80,6 +90,7 @@ describe("getAdminUser()", () => {
|
||||
id: "user-1",
|
||||
email: "no-brand@example.com",
|
||||
name: "No Brand",
|
||||
role: "brand_admin",
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -100,8 +111,8 @@ describe("getAdminUser()", () => {
|
||||
});
|
||||
|
||||
it("returns a fully-populated AdminUser for a provisioned brand_admin", async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { user: { email: "admin@tuxedo.example" } } });
|
||||
mockSelect
|
||||
mockGetSession.mockResolvedValue({ data: { user: { email: "admin@tuxedo.example" } } });
|
||||
mockDb.select
|
||||
.mockReturnValueOnce({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
@@ -110,6 +121,7 @@ describe("getAdminUser()", () => {
|
||||
id: "user-tux",
|
||||
email: "admin@tuxedo.example",
|
||||
name: "Tux Admin",
|
||||
role: "brand_admin",
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -185,4 +197,4 @@ describe("permissionsForRole()", () => {
|
||||
expect(p.can_manage_products).toBe(false);
|
||||
expect(p.can_manage_billing).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user