diff --git a/.env.example b/.env.example index 8b72504..4ce73cd 100644 --- a/.env.example +++ b/.env.example @@ -9,51 +9,18 @@ NEXT_PUBLIC_BASE_URL=http://localhost:4000 NEXT_PUBLIC_SITE_URL=http://localhost:4000 -# ── Database (Postgres, direct — Supabase is being removed) ──────────────── -# Single connection string used by `pg.Pool` in src/lib/auth.ts and the +# ── Database (Neon Postgres, direct pg driver) ───────────────────────────── +# Single connection string used by the pg Pool in src/lib/auth.ts and the # admin-permissions / data-service layer. Format: # postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce -# ── Auth.js (NextAuth v5) ─────────────────────────────────────────────────── -# Generate with: npx auth secret -# Or: openssl rand -base64 32 -AUTH_SECRET=replace-me-with-a-32-byte-base64-string - -# Base URL used to build OAuth callback URLs. In dev: -AUTH_URL=http://localhost:4000 -# In production, set to https://yourdomain.com - -# Google OAuth provider. -# 1. Go to https://console.cloud.google.com/apis/credentials -# 2. Create an OAuth 2.0 Client ID (type: Web application) -# 3. Add Authorized redirect URI: http://localhost:3000/api/auth/callback/google -# 4. Copy client id + client secret below -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= - -# Auth.js also reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET if you prefer the -# default NextAuth variable names. The code in src/auth.config.ts falls -# back to those names if GOOGLE_CLIENT_ID is unset. - -# Set to "false" to disable the in-app dev credentials provider even in -# development. Default: enabled in dev only. -ALLOW_DEV_LOGIN=true - -# Comma-separated list of email addresses allowed to sign in via Google -# OAuth. If unset (or empty), any Google account can sign in and gets a -# `platform_admin` row auto-created — fine for demo/dev. Set this in -# production to lock sign-in down to a known set of admins. -# Example: ADMIN_ALLOWED_EMAILS=tyler@example.com,sarah@example.com -ADMIN_ALLOWED_EMAILS= - -# ── Supabase (legacy, being removed) ──────────────────────────────────────── -# Still used by the existing admin pages, server actions, and the -# `getAdminUser` flow. Once the auth migration is complete and the -# @supabase/* packages are removed, these can go away. -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= +# ── Neon Auth (Better Auth) ─────────────────────────────────────────────── +# Get these from: neonctl neon-auth status +# Base URL: "Auth Base URL" field +# Cookie secret: openssl rand -base64 32 (min 32 chars) +NEON_AUTH_BASE_URL=https://your-branch.neonauth.region.aws.neon.tech/neondb/auth +NEON_AUTH_COOKIE_SECRET=replace-me-with-a-32-char-secret # ── Stripe ───────────────────────────────────────────────────────────────── STRIPE_SECRET_KEY= @@ -73,7 +40,7 @@ STRIPE_PRICE_SMS_CAMPAIGNS= RESEND_API_KEY= RESEND_WEBHOOK_SECRET= -# ── AI providers ──────────────────────────────────────────────────────────── +# ── AI providers ────────────────────────────────────────────────────────── OPENAI_API_KEY= ANTHROPIC_API_KEY= GOOGLE_API_KEY= @@ -81,9 +48,23 @@ XAI_API_KEY= MINIMAX_API_KEY= MINIMAX_BASE_URL=https://api.minimax.io/v1 -# ── Square (optional) ─────────────────────────────────────────────────────── +# ── Square (optional) ───────────────────────────────────────────────────── SQUARE_APP_SECRET= SQUARE_ENVIRONMENT=sandbox +# ── MinIO / S3-compatible object storage ───────────────────────────────────── +# Endpoint: host:port only (no https://). Use MINIO_PUBLIC_URL for the public base. +MINIO_ENDPOINT=s3.crispygoat.com +MINIO_REGION=us-east-1 +MINIO_USE_SSL=true +MINIO_ACCESS_KEY=routecommerce +MINIO_SECRET_KEY=miniochangeme123 +# Override public URL if different from endpoint (e.g. Cloudflare CDN in front) +MINIO_PUBLIC_URL=https://s3.crispygoat.com +# Bucket names — create these in MinIO first (mc mb myminio/route-products, etc.) +MINIO_BUCKET_PRODUCTS=route-products +MINIO_BUCKET_BRAND_LOGOS=route-brand-logos +MINIO_BUCKET_WATER_LOGS=route-water-logs + # ── Cron / automation ─────────────────────────────────────────────────────── CRON_SECRET=replace-me-with-a-random-string diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fc78256..84122c4 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -15,264 +15,104 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' - - - name: Start Docker stack - env: - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - POSTGRES_DB: ${{ secrets.POSTGRES_DB }} - MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} - MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} - POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} - - # PostgREST — needs the DB URI at start time (it reads env - # from the container, not from .env.production which is - # written later by the Deploy step). - PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }} - PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }} - PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }} - run: | - APP_DIR=/home/tyler/route-commerce - mkdir -p $APP_DIR - - # Seed config files into APP_DIR FIRST, before any docker compose - # command. The `docker compose down` below validates the compose - # file (including `env_file` paths) — if the old copy is still - # on the server with a broken `env_file`, the step fails before - # we get a chance to overwrite it. - # - docker-compose.yml: copied UNCONDITIONALLY so deploys pick - # up compose changes. The previous `[ -f ... ] ||` guard - # kept stale copies on the server. - # - .env.example: copied on first deploy only (it's a template; - # the real `.env` is built from it below). - [ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example - cp -f deploy/docker-compose.yml $APP_DIR/docker-compose.yml - - # Free the dev-stack port (3001) and the port the previous deploy used - # (so a new deploy can pick it back up if it's the lowest free port) - PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "") - for port in 3001 $PREV_PORT; do - if [ -n "$port" ] && ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${port}[[:space:]]"; then - echo "Port $port in use, freeing..." - fuser -k -9 $port/tcp 2>/dev/null || true - docker ps -aq --filter "publish=$port" 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true - fi - done - - # Hard-stop the previous stack. Errors are NOT swallowed: if down - # fails, picking a port against a half-torn-down stack is exactly - # what produces the TOCTOU "address already in use" we keep hitting. - docker compose -f $APP_DIR/docker-compose.yml down --remove-orphans - # Belt-and-braces: anything with the postgrest name that survived. - docker ps -aq --filter "name=route_commerce_postgrest" | xargs -r docker rm -f >/dev/null 2>&1 || true - # docker-proxy sometimes leaves a listener behind for the published port. - pkill -9 -f 'docker-proxy.*3011' 2>/dev/null || true - pkill -9 -f 'docker-proxy.*3012' 2>/dev/null || true - pkill -9 -f 'docker-proxy.*3013' 2>/dev/null || true - sleep 3 - - # Verify the postgrest container is actually gone before we pick a port. - if docker ps -aq --filter "name=route_commerce_postgrest" | grep -q .; then - echo "ERROR: route_commerce_postgrest still running after down" - docker ps --filter "name=route_commerce_postgrest" - exit 1 - fi - - # Find the first free host port starting from 3011. Persist the choice - # so the Build and Deploy steps below can use the same URL. - POSTGREST_HOST_PORT=3011 - for attempt in 1 2 3 4 5 6 7 8 9 10; do - if ! ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${POSTGREST_HOST_PORT}[[:space:]]"; then - break - fi - echo "Port $POSTGREST_HOST_PORT in use, trying next... (attempt $attempt)" - POSTGREST_HOST_PORT=$((POSTGREST_HOST_PORT + 1)) - if [ $POSTGREST_HOST_PORT -gt 30200 ]; then - echo "ERROR: no free port in 3011-30200 range" - exit 1 - fi - sleep 1 - done - echo "Using PostgREST host port: $POSTGREST_HOST_PORT" - echo "$POSTGREST_HOST_PORT" > .postgrest-port - export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" - export POSTGREST_HOST_PORT - - cd $APP_DIR - [ -f .env ] || cp .env.example .env - # Append production secrets to .env (overriding .env.example defaults) - { - echo "POSTGRES_USER=${POSTGRES_USER}" - echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" - echo "POSTGRES_DB=${POSTGRES_DB}" - echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}" - echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" - echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}" - echo "PGRST_DB_URI=${PGRST_DB_URI}" - echo "PGRST_DB_ANON_ROLE=${PGRST_DB_ANON_ROLE:-anon}" - echo "PGRST_SERVER_PORT=${PGRST_SERVER_PORT:-3000}" - echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT" - echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" - } >> .env - # Bring the stack up fresh — --force-recreate ensures no stale - # network/container references from prior failed attempts. - # Only `postgrest` lives in docker; Postgres itself runs on the - # host (see the migrations step below, which uses - # `psql -h 127.0.0.1`). - docker compose up -d --force-recreate postgrest - # Wait for Postgres to accept connections on the host. - # The DB is on 127.0.0.1, not in a docker service. - for i in $(seq 1 30); do - if PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "SELECT 1" > /dev/null 2>&1; then - echo "Postgres is ready" - break - fi - sleep 2 - done - - - name: Apply migrations - env: - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - POSTGRES_DB: ${{ secrets.POSTGRES_DB }} - run: | - APP_DIR=/home/tyler/route-commerce - # Seed supabase/ into APP_DIR if missing (the deploy step copies it after, but - # we need it here for migrations) - [ -d $APP_DIR/supabase ] || cp -r supabase $APP_DIR/supabase - cd $APP_DIR - # PAGER= prevents psql from launching less/more in a non-interactive shell, - # which hangs indefinitely waiting for keypress. Batch all files into one - # connection for speed instead of one psql invocation per file. - export PAGER= - export PGPASSWORD="${POSTGRES_PASSWORD}" - PG="psql -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB} --no-psqlrc -v ON_ERROR_STOP=0 -q" - $PG -f supabase/migrations/000_preflight_supabase_compat.sql || true - [ -f supabase/captured_schema.sql ] && $PG -f supabase/captured_schema.sql || true - # Concatenate all numbered migrations and run in one session - cat supabase/migrations/[0-9]*.sql | $PG + node-version: "22" - name: Install dependencies run: npm install + - name: Run migrations + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + npm run migrate:one || echo "No migration script or migrations already applied" + - name: Build env: NODE_ENV: production DATABASE_URL: ${{ secrets.DATABASE_URL }} - - # Auth.js v5 (NextAuth). Fall back to Better Auth names if the - # Gitea secret hasn't been renamed yet. - AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }} - AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }} - NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }} - GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} - GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} + NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }} + NEON_AUTH_BASE_URL: ${{ secrets.NEON_AUTH_BASE_URL }} + NEON_AUTH_COOKIE_SECRET: ${{ secrets.NEON_AUTH_COOKIE_SECRET }} + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + AUTH_URL: ${{ secrets.AUTH_URL }} + NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }} - - # Supabase (legacy, still used by admin pages/server actions until - # the Auth.js migration is finished) - NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} - NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} - - # Storage (MinIO / S3) - NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }} - STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} - STORAGE_REGION: ${{ secrets.STORAGE_REGION }} - STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }} - STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }} - STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }} - - # Stripe + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} - STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} - - # Resend + STRIPE_PRICE_STARTER: ${{ secrets.STRIPE_PRICE_STARTER }} + STRIPE_PRICE_FARM: ${{ secrets.STRIPE_PRICE_FARM }} + STRIPE_PRICE_ENTERPRISE: ${{ secrets.STRIPE_PRICE_ENTERPRISE }} + STRIPE_PRICE_HARVEST_REACH: ${{ secrets.STRIPE_PRICE_HARVEST_REACH }} + STRIPE_PRICE_WHOLESALE_PORTAL: ${{ secrets.STRIPE_PRICE_WHOLESALE_PORTAL }} + STRIPE_PRICE_WATER_LOG: ${{ secrets.STRIPE_PRICE_WATER_LOG }} + STRIPE_PRICE_AI_TOOLS: ${{ secrets.STRIPE_PRICE_AI_TOOLS }} + STRIPE_PRICE_SQUARE_SYNC: ${{ secrets.STRIPE_PRICE_SQUARE_SYNC }} + STRIPE_PRICE_SMS_CAMPAIGNS: ${{ secrets.STRIPE_PRICE_SMS_CAMPAIGNS }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} - RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }} - - # AI providers + FROM_EMAIL: ${{ secrets.FROM_EMAIL }} + MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }} + MINIO_REGION: ${{ secrets.MINIO_REGION }} + MINIO_ACCESS_KEY: ${{ secrets.MINIO_ACCESS_KEY }} + MINIO_SECRET_KEY: ${{ secrets.MINIO_SECRET_KEY }} + MINIO_PUBLIC_URL: ${{ secrets.MINIO_PUBLIC_URL }} + MINIO_BUCKET_PRODUCTS: ${{ secrets.MINIO_BUCKET_PRODUCTS }} + MINIO_BUCKET_BRAND_LOGOS: ${{ secrets.MINIO_BUCKET_BRAND_LOGOS }} + MINIO_BUCKET_WATER_LOGS: ${{ secrets.MINIO_BUCKET_WATER_LOGS }} MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }} - - # Email sender - FROM_EMAIL: ${{ secrets.FROM_EMAIL }} - run: | - POSTGREST_HOST_PORT=$(cat .postgrest-port) - export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" - npm run build + run: npm run build - name: Deploy env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - POSTGRES_DB: ${{ secrets.POSTGRES_DB }} - MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} - MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} - POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} - - # Auth.js v5 (with Better Auth fallback for the secret name) - AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }} - AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }} - NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }} - GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} - GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} + NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }} + NEON_AUTH_BASE_URL: ${{ secrets.NEON_AUTH_BASE_URL }} + NEON_AUTH_COOKIE_SECRET: ${{ secrets.NEON_AUTH_COOKIE_SECRET }} + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + AUTH_URL: ${{ secrets.AUTH_URL }} + NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }} - - # Storage - STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} - STORAGE_REGION: ${{ secrets.STORAGE_REGION }} - STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }} - STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }} - STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }} - NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }} - - # PostgREST - PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }} - PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }} - PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }} - PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} - - # Supabase (legacy) - NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} - NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - - # Stripe + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} - STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} - - # Resend + STRIPE_PRICE_STARTER: ${{ secrets.STRIPE_PRICE_STARTER }} + STRIPE_PRICE_FARM: ${{ secrets.STRIPE_PRICE_FARM }} + STRIPE_PRICE_ENTERPRISE: ${{ secrets.STRIPE_PRICE_ENTERPRISE }} + STRIPE_PRICE_HARVEST_REACH: ${{ secrets.STRIPE_PRICE_HARVEST_REACH }} + STRIPE_PRICE_WHOLESALE_PORTAL: ${{ secrets.STRIPE_PRICE_WHOLESALE_PORTAL }} + STRIPE_PRICE_WATER_LOG: ${{ secrets.STRIPE_PRICE_WATER_LOG }} + STRIPE_PRICE_AI_TOOLS: ${{ secrets.STRIPE_PRICE_AI_TOOLS }} + STRIPE_PRICE_SQUARE_SYNC: ${{ secrets.STRIPE_PRICE_SQUARE_SYNC }} + STRIPE_PRICE_SMS_CAMPAIGNS: ${{ secrets.STRIPE_PRICE_SMS_CAMPAIGNS }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} - RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }} - - # AI + FROM_EMAIL: ${{ secrets.FROM_EMAIL }} + MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }} + MINIO_REGION: ${{ secrets.MINIO_REGION }} + MINIO_ACCESS_KEY: ${{ secrets.MINIO_ACCESS_KEY }} + MINIO_SECRET_KEY: ${{ secrets.MINIO_SECRET_KEY }} + MINIO_PUBLIC_URL: ${{ secrets.MINIO_PUBLIC_URL }} + MINIO_BUCKET_PRODUCTS: ${{ secrets.MINIO_BUCKET_PRODUCTS }} + MINIO_BUCKET_BRAND_LOGOS: ${{ secrets.MINIO_BUCKET_BRAND_LOGOS }} + MINIO_BUCKET_WATER_LOGS: ${{ secrets.MINIO_BUCKET_WATER_LOGS }} MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }} - - FROM_EMAIL: ${{ secrets.FROM_EMAIL }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} run: | APP_DIR=/home/tyler/route-commerce - mkdir -p $APP_DIR - # Use the port chosen by Start Docker stack (persisted to .postgrest-port) - POSTGREST_HOST_PORT=$(cat .postgrest-port) - export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" - # Write env file from secrets (preserves existing .env for docker compose) + # Write production env file { printf "DATABASE_URL=%s\n" "$DATABASE_URL" - printf "NEXT_PUBLIC_API_URL=%s\n" "$NEXT_PUBLIC_API_URL" - printf "POSTGRES_USER=%s\n" "$POSTGRES_USER" - printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD" - printf "POSTGRES_DB=%s\n" "$POSTGRES_DB" - printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER" - printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD" - printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET" + 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" @@ -280,41 +120,44 @@ jobs: 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 "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT" - printf "STORAGE_REGION=%s\n" "$STORAGE_REGION" - printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY" - printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY" - printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX" - printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL" - printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT" - printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI" - printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE" - printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET" - printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL" - printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY" + 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_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY" + 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 "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET" + 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 "FROM_EMAIL=%s\n" "$FROM_EMAIL" + printf "CRON_SECRET=%s\n" "$CRON_SECRET" } > $APP_DIR/.env.production - # Copy build output and required files + # Sync build output and required files rsync -a --delete .next/ $APP_DIR/.next/ rsync -a --delete public/ $APP_DIR/public/ cp package.json $APP_DIR/ - cp deploy/docker-compose.yml $APP_DIR/ - cp -r supabase/ $APP_DIR/ - cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true + cp next.config.ts $APP_DIR/ 2>/dev/null || true # Install production deps only cd $APP_DIR npm install --omit=dev - # Start or restart PM2 process + # Start or restart PM2 if pm2 describe route-commerce > /dev/null 2>&1; then pm2 restart route-commerce else diff --git a/.gitignore b/.gitignore index 1790433..aff1c6a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ playwright-report/ # IDE / local config .mcp.json .env* +public/videos/tuxedo-hero.mp4 diff --git a/CLAUDE.md b/CLAUDE.md index fd54837..f37d3e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,9 +16,9 @@ Do **not** add GitHub remotes. There is no `origin` on github.com and no separat Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns. -Tech stack: Next.js 16 (App Router) · **Postgres** (direct — Supabase is being removed) · Auth.js (NextAuth v5, in-progress migration from bespoke cookie auth) · Stripe · Square · Resend (email) · Tailwind CSS v4 +Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4 -> **Direction:** Supabase is being removed in favor of a direct Postgres connection. The `supabase/` directory is kept as a path for migrations tooling only (no Supabase platform/CLI/auth). Until the Auth.js migration ships, auth still flows through the `dev_session` / `rc_auth_uid` cookies — see the Authentication section. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST. +> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST. --- @@ -46,43 +46,47 @@ E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.sp ### Authentication & Authorization -**Auth.js v5 (NextAuth)** is the active auth system. Config lives in `src/lib/auth.ts`, with the route handler at `src/app/api/auth/[...nextauth]/route.ts`. The login page (`src/app/login/LoginClient.tsx`) renders a "Continue with Google" button alongside the email/password form, both backed by Auth.js providers. Server-side code reads the session via `await auth()` from `@/lib/auth`; client code that needs to sign out can call the `signOutAction` server action from `@/actions/auth-actions`. +**Neon Auth (Better Auth)** is the active auth system. Config lives in `src/lib/auth.ts`, with the route handler at `src/app/api/auth/[...nextauth]/route.ts`. Neon Auth manages users in the `neon_auth.user` table; our `admin_users` table links to Neon Auth users by email. -**Providers (see `src/lib/auth.ts`):** -- **Google OAuth** — primary sign-in. Active when `AUTH_GOOGLE_ID` + `AUTH_GOOGLE_SECRET` are set. Users auto-provision as `platform_admin` if their Google `sub` is UUID-shaped (rare) — for Google sign-ins, `admin_users` rows must be provisioned manually by an existing admin until the `email`-based provisioning flow lands. -- **Email/password (Supabase-backed)** — wraps the existing `auth/v1/token?grant_type=password` flow. This is transitional; once Supabase auth is fully removed, this provider goes away. +**Auth flow:** +1. User signs in via `/api/auth/sign-in` → Neon Auth validates credentials → session cookie set +2. `getAdminUser()` in `src/lib/admin-permissions.ts` reads the Neon Auth session and looks up `admin_users` by email +3. Middleware (`src/middleware.ts`) guards `/admin/*` routes at the edge level -**Demo / dev mode** still works through a `dev_session` cookie: -- `dev_session=platform_admin` — full access, all brands -- `dev_session=brand_admin` — full access to assigned brand only -- `dev_session=store_employee` — limited access (orders, pickup, wholesale only) -- The login page renders "Demo Mode" buttons that set this cookie client-side; the middleware also auto-issues `dev_session=platform_admin` for the `/admin` demo flow when Supabase isn't configured. +**Key files:** +- `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset) +- `src/auth.config.ts` — Edge-safe config (baseUrl, cookieSecret) +- `src/middleware.ts` — Edge-level route protection +- `src/app/api/auth/sign-in/route.ts` — Email/password sign-in API +- `src/app/api/auth/forgot-password/route.ts` — Password reset request API +- `src/app/api/auth/reset-password/route.ts` — Password reset confirmation API +- `src/actions/auth-actions.ts` — Server actions (signOutAction) +- `src/actions/admin/password.ts` — Admin password update (setUserPassword) -**Single source of truth for the current admin user:** `getAdminUser()` in `src/lib/admin-permissions.ts`. It reads `dev_session` first, then the Auth.js session via `auth()` from `@/lib/auth`. The Supabase `admin_users` lookup still uses `rest/v1/admin_users?user_id=eq.` — when the `pg` pool at `src/lib/db.ts` lands, that lookup should switch to a direct `SELECT`. **Never import `admin-permissions.ts` into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead. +**Environment variables:** +- `NEON_AUTH_BASE_URL` — from `neonctl neon-auth status` +- `NEON_AUTH_COOKIE_SECRET` — min 32-char secret: `openssl rand -base64 32` +- `NEXT_PUBLIC_SITE_URL` — site URL for redirects and Origin header -The middleware (`src/middleware.ts`) uses Auth.js v5's `auth()` wrapper. It guards `/admin/*` and `/login`, preserves the `dev_session` bypass, and adds baseline security headers. +**Single source of truth for the current admin user:** `getAdminUser()` in `src/lib/admin-permissions.ts`. It reads the Neon Auth session via `getSession()` from `@/lib/auth`, then looks up `admin_users` by email. **Never import `admin-permissions.ts` into Client Components** — use server actions instead. The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary. -#### Migration status +#### Auth API routes -- ✅ Auth.js v5 installed (`next-auth@beta`, currently `5.0.0-beta.31`) -- ✅ `src/lib/auth.ts` + `src/app/api/auth/[...nextauth]/route.ts` in place -- ✅ Google + Credentials (Supabase-backed) providers configured -- ✅ `getAdminUser()` reads from `auth()` session -- ✅ Middleware uses `auth()` wrapper -- ✅ Old `/api/login`, `/api/logout`, `/api/auth/uid`, `/api/set-auth-cookie` removed -- ⏳ Add `email` column to `admin_users` and provision Google users by email (TODO) -- ⏳ Switch the `admin_users` lookup in `getAdminUser()` to direct `pg` (TODO — needs `src/lib/db.ts`) -- ⏳ Remove the email/password (Supabase) provider when Supabase auth is fully cut over (TODO) -- ⏳ The `rc_auth_uid` cookie is no longer the source of truth, but `actions/admin/users.ts` still reads it for backward compat with pre-existing sessions — the `DEV_FORCE_UID` constant and its branches are now dead code (the `/api/force-admin` route that set it was deleted) and should be removed in a follow-up +| Route | Method | Purpose | +|-------|--------|---------| +| `/api/auth/sign-in` | POST | Email/password sign-in | +| `/api/auth/forgot-password` | POST | Request password reset email | +| `/api/auth/reset-password` | POST | Set new password (after reset link clicked) | +| `/api/auth/[...nextauth]` | GET/POST | Neon Auth handler (sign-out, OAuth callbacks, etc.) | ### Server Actions Pattern All database writes go through server actions in `src/actions/`. These: 1. Call `getAdminUser()` to verify auth 2. Check role/permission flags (`can_manage_orders`, etc.) -3. Call Supabase REST APIs (not the Supabase client directly in server actions) to trigger SECURITY DEFINER RPCs +3. Call Postgres RPCs via the `pg` driver 4. Return typed results (`{ success: true, ... } | { success: false, error: string }`) Server actions are "use server" files that export async functions. Client components import and call them directly. @@ -266,13 +270,15 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act | Concern | Location | |---|---| +| Neon Auth configuration | `src/lib/auth.ts`, `src/auth.config.ts` | +| Auth API routes | `src/app/api/auth/sign-in/route.ts`, `src/app/api/auth/forgot-password/route.ts`, `src/app/api/auth/reset-password/route.ts`, `src/app/api/auth/[...nextauth]/route.ts` | | Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` | | Middleware (route protection) | `src/middleware.ts` | | Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) | | Admin pages | `src/app/admin/[module]/page.tsx` | | Admin client components | `src/components/admin/*.tsx` | -| Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) | -| Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) | +| Migrations | `db/migrations/` | +| Postgres pool / driver | `src/lib/db.ts` (TBD) | | Email templates | `src/lib/email-templates.ts` | | Date formatting | `src/lib/format-date.ts` | | Feature flags | `src/lib/feature-flags.ts` | @@ -290,7 +296,8 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act - **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone. - **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have no row-level policies. All brand scoping must be enforced in server actions. -- **Supabase residue in the wild**: `grep -r "@supabase" src/` will still find imports during the transition. Do not add new ones; if you're touching a file that imports from Supabase, replace the call with the equivalent `pg`-pool call before merging. - **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead. - **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item. -- **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`. \ No newline at end of file +- **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`. +- **Neon Auth session cookie**: The session cookie is managed by Neon Auth. Do not manually set or clear it — use `signOut()` from `@/lib/auth` instead. +- **Password reset**: The forgot-password API returns success even if the email doesn't exist (to prevent email enumeration). Check server logs to verify reset emails were sent. \ No newline at end of file diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 03a0ad8..e6cf707 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -169,4 +169,50 @@ Controls whether to use Square sandbox or production. - **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync. - **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type. - **SUPABASE_SERVICE_ROLE_KEY in client:** If you ever see `SUPABASE_SERVICE_ROLE_KEY` in a browser bundle, it's a critical security incident. The key was exposed server-side. Rotate it immediately in Supabase Dashboard. -- **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key. \ No newline at end of file +- **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key. + +--- + +## Authentication + +### Production (HTTPS Required) + +Neon Auth session cookies use the `__Secure-` prefix, which requires HTTPS. In production, the auth flow works as follows: + +1. User submits credentials to `/api/auth/sign-in` +2. Neon Auth server sets session cookie with `secure: true` +3. Browser stores the cookie and sends it with subsequent requests +4. Middleware validates the session cookie + +### Local Development (HTTP) + +For local development over HTTP (e.g., `http://localhost:4000`), the platform provides a dev_session bypass: + +1. **Via Login Page:** Visit `/login` - you'll see "Dev Mode — Quick Access" buttons for Platform Admin, Brand Admin, and Store Employee roles. +2. **Via Browser Console:** `document.cookie = 'dev_session=platform_admin; path=/; max-age=86400'` +3. **Via curl:** `curl -b "dev_session=platform_admin" http://localhost:4000/admin` + +The dev_session cookie is automatically recognized by: +- The middleware (`src/proxy.ts`) +- The `getAdminUser()` function (`src/lib/admin-permissions.ts`) + +**Note:** The dev_session bypass only works when `NODE_ENV !== "production"`. In production, only Neon Auth session cookies are accepted. + +### HTTPS for Local Development + +If you prefer to test with real auth over HTTPS locally, you can use a tool like [mkcert](https://github.com/FiloSottile/mkcert): + +```bash +# Install mkcert +brew install mkcert + +# Create local CA and install it +mkcert -install + +# Generate certificate for localhost +mkcert localhost 127.0.0.1 ::1 + +# Update next.config.ts to use HTTPS +``` + +For most development work, the dev_session bypass is sufficient. \ No newline at end of file diff --git a/db/client.ts b/db/client.ts index e9eb778..950d9af 100644 --- a/db/client.ts +++ b/db/client.ts @@ -1,24 +1,24 @@ /** - * Drizzle client + tenant-scoped query helper. + * Drizzle client + brand-scoped query helper. * * The app connects to Postgres directly via the `pg` driver. Drizzle sits - * on top, providing typed queries. The `withTenant` wrapper is the only - * sanctioned way to run a tenant-scoped query — it sets the - * `app.current_tenant_id` GUC transaction-locally, and the database's - * RLS policies enforce tenant isolation even if application code forgets - * a `WHERE tenant_id = $1`. + * on top, providing typed queries. The `withBrand` wrapper is the only + * sanctioned way to run a brand-scoped query — it sets the + * `app.current_brand_id` GUC transaction-locally, and the database's + * RLS policies enforce brand isolation even if application code forgets + * a `WHERE brand_id = $1`. * * Usage (read): - * const products = await withTenant(tenantId, (db) => + * const products = await withBrand(brandId, (db) => * db.select().from(productsTable).where(eq(productsTable.active, true)), * ); * - * Usage (platform admin — sees all tenants): - * const allTenants = await withPlatformAdmin((db) => - * db.select().from(tenantsTable), + * Usage (platform admin — sees all brands): + * const allBrands = await withPlatformAdmin((db) => + * db.select().from(brandsTable), * ); * - * Usage (no tenant — for the rare case the query isn't tenant-scoped): + * Usage (no brand — for the rare case the query isn't brand-scoped): * const plans = await withDb((db) => db.select().from(plansTable)); */ @@ -57,10 +57,10 @@ function getPool(): Pool { } /** - * Run `fn` with a Drizzle client. No tenant context is set — the caller + * Run `fn` with a Drizzle client. No brand context is set — the caller * is responsible for RLS bypass (e.g. for the `plans` and `add_ons` tables, - * which are not tenant-scoped). For tenant-scoped reads, prefer - * `withTenant` or `withPlatformAdmin`. + * which are not brand-scoped). For brand-scoped reads, prefer + * `withBrand` or `withPlatformAdmin`. */ export async function withDb(fn: (db: Db) => Promise): Promise { const client = await getPool().connect(); @@ -73,22 +73,22 @@ export async function withDb(fn: (db: Db) => Promise): Promise { } /** - * Run `fn` inside a transaction with the current tenant id set as a - * transaction-local GUC. RLS policies on tenant-scoped tables will allow - * reads/writes only for rows where `tenant_id` matches. Pass `null` to + * Run `fn` inside a transaction with the current brand id set as a + * transaction-local GUC. RLS policies on brand-scoped tables will allow + * reads/writes only for rows where `brand_id` matches. Pass `null` to * fail open (don't set the GUC) — only useful for the migrations * themselves, never for app code. */ -export async function withTenant( - tenantId: string, +export async function withBrand( + brandId: string, fn: (db: Db) => Promise, ): Promise { return runInTransaction(async (client) => { // set_config(setting, value, is_local) — is_local=true makes it // transaction-local so it auto-resets at COMMIT/ROLLBACK and never // leaks across pooled connections. - await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [ - tenantId, + await client.query("SELECT set_config('app.current_brand_id', $1, true)", [ + brandId, ]); await client.query("SELECT set_config('app.platform_admin', 'false', true)"); const db = drizzle(client, { schema }); @@ -97,13 +97,14 @@ export async function withTenant( } /** - * Run `fn` as platform admin. RLS policies permit access to all tenants. + * Run `fn` as platform admin. RLS policies permit access to all brands. * Use sparingly — typically only in the /admin/platform routes. */ export async function withPlatformAdmin( fn: (db: Db) => Promise, ): Promise { return runInTransaction(async (client) => { + await client.query("SELECT set_config('app.current_brand_id', '', true)"); await client.query("SELECT set_config('app.platform_admin', 'true', true)"); const db = drizzle(client, { schema }); return fn(db); diff --git a/db/migrations/0001_init.sql b/db/migrations/0001_init.sql index 2b8a4ac..3d29867 100644 --- a/db/migrations/0001_init.sql +++ b/db/migrations/0001_init.sql @@ -1,77 +1,328 @@ -- 0001_init.sql -- --- Route Commerce SaaS schema. Single migration, idempotent, no DROP. --- Follows CLAUDE.md conventions: --- - Status enums as TEXT with CHECK (no PG ENUM type) --- - TIMESTAMPTZ everywhere --- - gen_random_uuid() for PKs --- - CREATE OR REPLACE FUNCTION for helpers +-- Route Commerce — Complete Schema +-- Neon Postgres (Neon Auth) + Application Schema -- --- Multi-tenancy: every business table has `tenant_id` and an RLS policy --- that compares it to `current_setting('app.current_tenant_id')`. The --- application MUST set this GUC (transaction-local) before any query --- against a tenant-scoped table. See `db/client.ts` for the wrapper. +-- Fresh start: drops stub tables, creates full Route Commerce schema. +-- Neon Auth tables (users, accounts, sessions, verifications) are preserved. -- --- Platform admin (sees all tenants) sets `app.platform_admin = 'true'` --- instead. RLS policies allow that to bypass the tenant filter. +-- Conventions: +-- - Status enums as TEXT + CHECK (no PG ENUM type) +-- - TIMESTAMPTZ everywhere for timestamps +-- - gen_random_uuid() for all UUID PKs +-- - CREATE OR REPLACE for helpers; CREATE TABLE IF NOT EXISTS for tables +-- - All business tables are brand-scoped (brand_id FK → brands.id) +-- - SECURITY DEFINER RPCs for all data access +-- - RLS on tenant-scoped tables; bypass via is_platform_admin() GUC +-- +-- Brand scoping: every brand-scoped table has brand_id and an RLS policy +-- that compares it to current_setting('app.current_brand_id'). The +-- application MUST set this GUC (transaction-local) before any query. +-- Platform admin sets app.platform_admin='true' to bypass. +-- +-- ============================================================================ BEGIN; --- ════════════════════════════════════════════════════════════════════════ +-- ============================================================================ -- 0. Extensions --- ════════════════════════════════════════════════════════════════════════ +-- ============================================================================ CREATE EXTENSION IF NOT EXISTS pgcrypto; --- ════════════════════════════════════════════════════════════════════════ --- 1. Tenancy + auth --- ════════════════════════════════════════════════════════════════════════ +-- ============================================================================ +-- 1. Brands (Tenants) +-- Drop stub, recreate with full schema. +-- ============================================================================ -CREATE TABLE IF NOT EXISTS tenants ( +DROP TABLE IF EXISTS items CASCADE; +DROP TABLE IF EXISTS addons CASCADE; +DROP TABLE IF EXISTS subscriptions CASCADE; +DROP TABLE IF EXISTS plans CASCADE; +DROP TABLE IF EXISTS products CASCADE; +DROP TABLE IF EXISTS brands CASCADE; + +CREATE TABLE brands ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, - status TEXT NOT NULL DEFAULT 'trial' - CHECK (status IN ('trial', 'active', 'past_due', 'suspended', 'churned')), - trial_ends_at TIMESTAMPTZ, + plan_tier TEXT NOT NULL DEFAULT 'starter' + CHECK (plan_tier IN ('starter', 'farm', 'enterprise')), + max_users INTEGER NOT NULL DEFAULT 2, + max_products INTEGER NOT NULL DEFAULT 25, + max_stops_monthly INTEGER NOT NULL DEFAULT 10, stripe_customer_id TEXT, + stripe_subscription_id TEXT, + stripe_subscription_status TEXT + CHECK (stripe_subscription_status IN ('trialing', 'active', 'past_due', 'canceled', 'incomplete', 'incomplete_expired')), + stripe_current_period_end TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TABLE IF NOT EXISTS users ( +CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ +BEGIN NEW.updated_at = now(); RETURN NEW; END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER brands_updated_at BEFORE UPDATE ON brands + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- ============================================================================ +-- 2. Admin Users + Multi-brand +-- Links to Neon Auth users table (neon_neon_auth.user) via email. +-- ============================================================================ + +CREATE TABLE admin_users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT UNIQUE, + user_id UUID REFERENCES neon_auth.user(id) ON DELETE CASCADE, + email TEXT NOT NULL, name TEXT, - image TEXT, - auth_provider TEXT NOT NULL DEFAULT 'dev' - CHECK (auth_provider IN ('dev', 'google', 'email')), - auth_subject TEXT, - email_verified_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX IF NOT EXISTS users_auth_subject_idx - ON users (auth_provider, auth_subject) - WHERE auth_subject IS NOT NULL; - -CREATE TABLE IF NOT EXISTS tenant_users ( - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'brand_admin' CHECK (role IN ('platform_admin', 'brand_admin', 'store_employee')), + can_manage_orders BOOLEAN NOT NULL DEFAULT true, + can_manage_products BOOLEAN NOT NULL DEFAULT true, + can_manage_stops BOOLEAN NOT NULL DEFAULT true, + can_manage_customers BOOLEAN NOT NULL DEFAULT true, + can_manage_wholesale BOOLEAN NOT NULL DEFAULT false, + can_manage_billing BOOLEAN NOT NULL DEFAULT false, + can_manage_settings BOOLEAN NOT NULL DEFAULT false, + can_manage_water_log BOOLEAN NOT NULL DEFAULT false, + can_manage_time_tracking BOOLEAN NOT NULL DEFAULT false, + can_manage_route_trace BOOLEAN NOT NULL DEFAULT false, + can_manage_reports BOOLEAN NOT NULL DEFAULT true, + can_manage_communications BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (tenant_id, user_id) + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id), + UNIQUE (email) ); -CREATE INDEX IF NOT EXISTS tenant_users_user_idx ON tenant_users (user_id); +CREATE TRIGGER admin_users_updated_at BEFORE UPDATE ON admin_users + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); --- ════════════════════════════════════════════════════════════════════════ --- 2. Billing --- ════════════════════════════════════════════════════════════════════════ +CREATE TABLE admin_user_brands ( + admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + added_by UUID REFERENCES admin_users(id), + PRIMARY KEY (admin_user_id, brand_id) +); -CREATE TABLE IF NOT EXISTS plans ( +-- ============================================================================ +-- 3. Products +-- ============================================================================ + +CREATE TABLE products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + sku TEXT, + type TEXT NOT NULL DEFAULT 'standard' + CHECK (type IN ('standard', 'wholesale', 'both')), + price_cents INTEGER NOT NULL CHECK (price_cents >= 0), + inventory INTEGER NOT NULL DEFAULT 0, + unit TEXT, + active BOOLEAN NOT NULL DEFAULT true, + is_taxable BOOLEAN NOT NULL DEFAULT false, + pickup_type TEXT NOT NULL DEFAULT 'all' + CHECK (pickup_type IN ('pickup', 'ship', 'all')), + image_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER products_updated_at BEFORE UPDATE ON products + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX products_brand_idx ON products (brand_id); +CREATE INDEX products_active_idx ON products (brand_id, active); + +-- ============================================================================ +-- 4. Product Images +-- ============================================================================ + +CREATE TABLE product_images ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, + storage_key TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + alt_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX product_images_product_idx ON product_images (product_id); + +-- ============================================================================ +-- 5. Stops / Locations +-- ============================================================================ + +CREATE TABLE stops ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + name TEXT NOT NULL, + location TEXT NOT NULL, + address TEXT, + city TEXT, + state TEXT, + zip TEXT, + date DATE NOT NULL, + "time" TEXT, + cutoff_date DATE, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'paused', 'closed')), + is_public BOOLEAN NOT NULL DEFAULT true, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER stops_updated_at BEFORE UPDATE ON stops + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX stops_brand_idx ON stops (brand_id); +CREATE INDEX stops_status_idx ON stops (brand_id, status); +CREATE INDEX stops_date_idx ON stops (brand_id, date); + +CREATE TABLE locations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + name TEXT NOT NULL, + address TEXT, + city TEXT, + state TEXT, + zip TEXT, + phone TEXT, + contact_name TEXT, + contact_email TEXT, + notes TEXT, + active BOOLEAN NOT NULL DEFAULT true, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER locations_updated_at BEFORE UPDATE ON locations + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- ============================================================================ +-- 5. Customers +-- ============================================================================ + +CREATE TABLE customers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + primary_email TEXT, + primary_phone TEXT, + first_name TEXT, + last_name TEXT, + source TEXT NOT NULL DEFAULT 'system', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT customers_email_or_phone CHECK (primary_email IS NOT NULL OR primary_phone IS NOT NULL) +); + +CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX customers_brand_idx ON customers (brand_id); + +-- ============================================================================ +-- 6. Orders +-- ============================================================================ + +CREATE TABLE orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + customer_id UUID REFERENCES customers(id) ON DELETE SET NULL, + stop_id UUID REFERENCES stops(id) ON DELETE SET NULL, + total_cents INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'confirmed', 'fulfilled', 'canceled')), + fulfillment TEXT NOT NULL + CHECK (fulfillment IN ('pickup', 'ship', 'mixed')), + customer_address TEXT, + customer_city TEXT, + customer_state TEXT, + customer_zip TEXT, + idempotency_key TEXT, + notes TEXT, + placed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER orders_updated_at BEFORE UPDATE ON orders + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX orders_brand_idx ON orders (brand_id); +CREATE INDEX orders_status_idx ON orders (brand_id, status); +CREATE INDEX orders_stop_idx ON orders (stop_id); +CREATE INDEX orders_customer_idx ON orders (customer_id); + +CREATE TABLE order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + product_id UUID REFERENCES products(id) ON DELETE SET NULL, + quantity INTEGER NOT NULL CHECK (quantity > 0), + price_cents INTEGER NOT NULL CHECK (price_cents >= 0), + fulfillment TEXT NOT NULL CHECK (fulfillment IN ('pickup', 'ship')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX order_items_order_idx ON order_items (order_id); + +-- ============================================================================ +-- 7. Brand Settings + Features +-- ============================================================================ + +CREATE TABLE brand_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, + legal_business_name TEXT, + phone TEXT, + email TEXT, + website_url TEXT, + street_address TEXT, + city TEXT, + state TEXT, + postal_code TEXT, + country TEXT DEFAULT 'US', + logo_url TEXT, + logo_url_dark TEXT, + hero_image_url TEXT, + tagline TEXT, + about_html TEXT, + primary_color TEXT DEFAULT '#0F766E', + default_email_signature TEXT, + invoice_footer_notes TEXT, + from_email TEXT, + from_name TEXT, + reply_to_email TEXT, + custom_footer_text TEXT, + feature_flags JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER brand_settings_updated_at BEFORE UPDATE ON brand_settings + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE brand_features ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + feature_key TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT false, + enabled_at TIMESTAMPTZ, + disabled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (brand_id, feature_key) +); + +-- ============================================================================ +-- 8. Billing — Plans + Add-ons +-- ============================================================================ + +CREATE TABLE plans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code TEXT NOT NULL UNIQUE CHECK (code IN ('starter', 'farm', 'enterprise')), @@ -80,20 +331,16 @@ CREATE TABLE IF NOT EXISTS plans ( max_users INTEGER NOT NULL, max_products INTEGER NOT NULL, max_stops_monthly INTEGER NOT NULL, - features JSONB NOT NULL DEFAULT '[]'::jsonb, + features JSONB NOT NULL DEFAULT '[]', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TABLE IF NOT EXISTS add_ons ( +CREATE TABLE add_ons ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code TEXT NOT NULL UNIQUE CHECK (code IN ( - 'wholesale_portal', - 'harvest_reach', - 'ai_tools', - 'water_log', - 'square_sync', - 'sms_campaigns' + 'wholesale_portal', 'harvest_reach', 'ai_tools', + 'water_log', 'square_sync', 'sms_campaigns' )), name TEXT NOT NULL, monthly_price_cents INTEGER NOT NULL CHECK (monthly_price_cents >= 0), @@ -101,423 +348,1461 @@ CREATE TABLE IF NOT EXISTS add_ons ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE TABLE IF NOT EXISTS subscriptions ( - tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE, - plan_id UUID NOT NULL REFERENCES plans(id), - status TEXT NOT NULL DEFAULT 'trialing' - CHECK (status IN ('trialing', 'active', 'past_due', 'canceled', 'incomplete')), - stripe_subscription_id TEXT, - current_period_end TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS tenant_add_ons ( - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, +CREATE TABLE brand_add_ons ( + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, add_on_id UUID NOT NULL REFERENCES add_ons(id) ON DELETE CASCADE, stripe_subscription_id TEXT, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'canceled')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (tenant_id, add_on_id) + PRIMARY KEY (brand_id, add_on_id) ); --- ════════════════════════════════════════════════════════════════════════ --- 3. Products --- ════════════════════════════════════════════════════════════════════════ +-- Seed plans +INSERT INTO plans (code, name, monthly_price_cents, max_users, max_products, max_stops_monthly, features) VALUES + ('starter', 'Starter', 4900, 1, 25, 10, '["products", "stops", "orders", "pickup"]'), + ('farm', 'Farm', 14900, 5, -1, -1, '["products", "stops", "orders", "pickup", "ship", "wholesale_portal", "harvest_reach"]'), + ('enterprise', 'Enterprise', 39900, -1, -1, -1, '["products", "stops", "orders", "pickup", "ship", "wholesale_portal", "harvest_reach", "ai_tools", "sms_campaigns", "square_sync", "water_log"]'); -CREATE TABLE IF NOT EXISTS products ( +-- Seed add-ons +INSERT INTO add_ons (code, name, monthly_price_cents, description) VALUES + ('wholesale_portal', 'Wholesale Portal', 9900, 'B2B self-service portal for wholesale customers'), + ('harvest_reach', 'Harvest Reach', 7900, 'Email/SMS campaigns, automation, stop-blast'), + ('ai_tools', 'AI Intelligence Pack', 5900, 'AI-powered campaign writer, pricing advisor, forecasting'), + ('water_log', 'Water Log', 3900, 'Irrigation and water usage tracking'), + ('square_sync', 'Square Sync', 3900, 'Bidirectional Square POS inventory sync'), + ('sms_campaigns', 'SMS Campaigns', 2900, 'SMS campaigns via Twilio'); + +-- ============================================================================ +-- 9. Wholesale Portal +-- ============================================================================ + +CREATE TABLE wholesale_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - price_cents INTEGER NOT NULL CHECK (price_cents >= 0), - inventory INTEGER NOT NULL DEFAULT 0, - unit TEXT, - active BOOLEAN NOT NULL DEFAULT true, + brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, + require_approval BOOLEAN NOT NULL DEFAULT true, + min_order_amount NUMERIC(10,2), + online_payment_enabled BOOLEAN NOT NULL DEFAULT false, + pickup_location TEXT, + fob_location TEXT, + from_email TEXT, + invoice_business_name TEXT, + last_invoice_number INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX IF NOT EXISTS products_tenant_idx ON products (tenant_id); -CREATE INDEX IF NOT EXISTS products_active_idx ON products (tenant_id, active); +CREATE TRIGGER wholesale_settings_updated_at BEFORE UPDATE ON wholesale_settings + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -CREATE TABLE IF NOT EXISTS product_images ( +CREATE TABLE wholesale_customers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, - storage_key TEXT NOT NULL, - position INTEGER NOT NULL DEFAULT 0, - alt_text TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS product_images_product_idx - ON product_images (product_id, position); - --- ════════════════════════════════════════════════════════════════════════ --- 4. Stops --- ════════════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS stops ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - name TEXT NOT NULL, - address TEXT NOT NULL, - schedule JSONB NOT NULL DEFAULT '[]'::jsonb, - status TEXT NOT NULL DEFAULT 'active' - CHECK (status IN ('active', 'paused', 'closed')), - notes TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS stops_tenant_idx ON stops (tenant_id); -CREATE INDEX IF NOT EXISTS stops_status_idx ON stops (tenant_id, status); - --- ════════════════════════════════════════════════════════════════════════ --- 5. Customers --- ════════════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS customers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - name TEXT NOT NULL, + user_id UUID REFERENCES neon_auth.user(id) ON DELETE SET NULL, + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + company_name TEXT, + contact_name TEXT, email TEXT, phone TEXT, - sms_opt_in BOOLEAN NOT NULL DEFAULT false, - email_opt_in BOOLEAN NOT NULL DEFAULT true, - notes TEXT, + billing_address TEXT, + shipping_address TEXT, + account_status TEXT NOT NULL DEFAULT 'active' + CHECK (account_status IN ('active', 'suspended', 'inactive')), + credit_limit NUMERIC(10,2) NOT NULL DEFAULT 0, + deposits_enabled BOOLEAN NOT NULL DEFAULT false, + deposit_threshold NUMERIC(10,2), + deposit_percentage INTEGER CHECK (deposit_percentage IS NULL OR deposit_percentage BETWEEN 1 AND 100), + order_email TEXT, + invoice_email TEXT, + admin_notes TEXT, + role TEXT NOT NULL DEFAULT 'buyer' + CHECK (role IN ('buyer', 'admin')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX IF NOT EXISTS customers_tenant_idx ON customers (tenant_id); -CREATE UNIQUE INDEX IF NOT EXISTS customers_tenant_email_idx - ON customers (tenant_id, email) - WHERE email IS NOT NULL; +CREATE TRIGGER wholesale_customers_updated_at BEFORE UPDATE ON wholesale_customers + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); --- ════════════════════════════════════════════════════════════════════════ --- 6. Orders --- ════════════════════════════════════════════════════════════════════════ +CREATE INDEX wholesale_customers_brand_idx ON wholesale_customers (brand_id); -CREATE TABLE IF NOT EXISTS orders ( +CREATE TABLE wholesale_products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - customer_id UUID REFERENCES customers(id) ON DELETE SET NULL, - total_cents INTEGER NOT NULL DEFAULT 0, + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + rc_product_id UUID REFERENCES products(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + unit_type TEXT NOT NULL DEFAULT 'each', + availability TEXT NOT NULL DEFAULT 'unavailable' + CHECK (availability IN ('available', 'unavailable', 'limited', 'seasonal')), + qty_available NUMERIC(10,2) DEFAULT 0, + price_tiers JSONB NOT NULL DEFAULT '[]', + hp_sku TEXT, + hp_item_id TEXT, + internal_notes TEXT, + handling_instructions TEXT, + transport_temp TEXT, + storage_warning TEXT, + loading_notes TEXT, + product_label TEXT, + pack_style TEXT, + container_type TEXT, + container_size_code TEXT, + units_per_container INTEGER, + container_notes TEXT, + default_pickup_location TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER wholesale_products_updated_at BEFORE UPDATE ON wholesale_products + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX wholesale_products_brand_idx ON wholesale_products (brand_id); + +CREATE TABLE wholesale_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE RESTRICT, + wc_order_id BIGINT, status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending', 'confirmed', 'fulfilled', 'canceled')), - fulfillment TEXT NOT NULL - CHECK (fulfillment IN ('pickup', 'ship', 'mixed')), - notes TEXT, - placed_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS orders_tenant_idx ON orders (tenant_id); -CREATE INDEX IF NOT EXISTS orders_customer_idx ON orders (customer_id); -CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (tenant_id, status); - -CREATE TABLE IF NOT EXISTS order_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, - product_id UUID REFERENCES products(id) ON DELETE SET NULL, - quantity INTEGER NOT NULL CHECK (quantity > 0), - price_cents INTEGER NOT NULL CHECK (price_cents >= 0), - fulfillment TEXT NOT NULL - CHECK (fulfillment IN ('pickup', 'ship')) -); - -CREATE INDEX IF NOT EXISTS order_items_order_idx ON order_items (order_id); - --- ════════════════════════════════════════════════════════════════════════ --- 7. Brand settings --- ════════════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS brand_settings ( - tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE, - brand_name TEXT NOT NULL, - tagline TEXT, - about_html TEXT, - primary_color TEXT DEFAULT '#0F766E', - logo_storage_key TEXT, - hero_storage_key TEXT, - contact_email TEXT, - contact_phone TEXT, - custom_footer_text TEXT, - feature_flags JSONB NOT NULL DEFAULT '{}'::jsonb, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- ════════════════════════════════════════════════════════════════════════ --- 8. Marketing --- ════════════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS email_templates ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - name TEXT NOT NULL, - subject TEXT NOT NULL, - body_html TEXT NOT NULL, + CHECK (status IN ('pending', 'confirmed', 'in_production', 'ready', 'fulfilled', 'canceled')), + fulfillment_status TEXT NOT NULL DEFAULT 'unfulfilled' + CHECK (fulfillment_status IN ('unfulfilled', 'partial', 'fulfilled')), + payment_status TEXT NOT NULL DEFAULT 'unpaid' + CHECK (payment_status IN ('unpaid', 'deposit_paid', 'paid', 'refunded')), + anticipated_pickup_date DATE, + subtotal NUMERIC(10,2) NOT NULL DEFAULT 0, + deposit_required NUMERIC(10,2) DEFAULT 0, + deposit_paid NUMERIC(10,2) NOT NULL DEFAULT 0, + balance_due NUMERIC(10,2) NOT NULL DEFAULT 0, + assigned_employee_id UUID REFERENCES neon_auth.user(id) ON DELETE SET NULL, + fulfillment_notes TEXT, + internal_notes TEXT, + invoice_number TEXT, + invoice_pdf_path TEXT, + invoice_token TEXT, + invoice_type TEXT, + deposit_percentage INTEGER, + fulfilled_at TIMESTAMPTZ, + fulfilled_by UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX IF NOT EXISTS email_templates_tenant_idx ON email_templates (tenant_id); +CREATE TRIGGER wholesale_orders_updated_at BEFORE UPDATE ON wholesale_orders + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -CREATE TABLE IF NOT EXISTS campaigns ( +CREATE INDEX wholesale_orders_brand_idx ON wholesale_orders (brand_id); +CREATE INDEX wholesale_orders_customer_idx ON wholesale_orders (customer_id); +CREATE INDEX wholesale_orders_status_idx ON wholesale_orders (brand_id, status); + +CREATE TABLE wholesale_order_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - template_id UUID REFERENCES email_templates(id) ON DELETE SET NULL, - name TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'draft' - CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'canceled')), - scheduled_for TIMESTAMPTZ, - sent_at TIMESTAMPTZ, - recipient_count INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS campaigns_tenant_idx ON campaigns (tenant_id); -CREATE INDEX IF NOT EXISTS campaigns_status_idx ON campaigns (tenant_id, status); - --- ════════════════════════════════════════════════════════════════════════ --- 9. Files --- ════════════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS files ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, - storage_key TEXT NOT NULL UNIQUE, - mime_type TEXT NOT NULL, - size_bytes BIGINT NOT NULL, - purpose TEXT, - uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL, + wholesale_order_id UUID NOT NULL REFERENCES wholesale_orders(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES wholesale_products(id) ON DELETE RESTRICT, + quantity NUMERIC(10,2) NOT NULL, + unit_price NUMERIC(10,2) NOT NULL, + line_total NUMERIC(10,2) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX IF NOT EXISTS files_tenant_idx ON files (tenant_id); +CREATE INDEX wholesale_order_items_order_idx ON wholesale_order_items (wholesale_order_id); --- ════════════════════════════════════════════════════════════════════════ --- 10. Audit log --- ════════════════════════════════════════════════════════════════════════ - -CREATE TABLE IF NOT EXISTS audit_log ( +CREATE TABLE wholesale_deposits ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, - user_id UUID REFERENCES users(id) ON DELETE SET NULL, + wholesale_order_id UUID NOT NULL REFERENCES wholesale_orders(id) ON DELETE CASCADE, + amount NUMERIC(10,2) NOT NULL, + payment_method TEXT CHECK (payment_method IN ('stripe', 'square', 'check', 'cash', 'other')), + reference TEXT, + recorded_by UUID NOT NULL REFERENCES neon_auth.user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE wholesale_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE, + notification_type TEXT NOT NULL + CHECK (notification_type IN ('order_confirmed', 'order_ready', 'pickup_reminder', 'payment_reminder', 'invoice')), + channel TEXT NOT NULL CHECK (channel IN ('email', 'sms')), + recipient TEXT NOT NULL, + subject TEXT, + body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'sent', 'failed')), + sent_at TIMESTAMPTZ, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX wholesale_notifications_brand_idx ON wholesale_notifications (brand_id); + +CREATE TABLE wholesale_customer_product_pricing ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES wholesale_products(id) ON DELETE CASCADE, + custom_price_cents INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (customer_id, product_id) +); + +CREATE TABLE wholesale_webhook_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, + url TEXT NOT NULL, + secret TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER wholesale_webhook_settings_updated_at BEFORE UPDATE ON wholesale_webhook_settings + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE wholesale_sync_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + order_id UUID, + payload JSONB, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'sent', 'failed')), + response TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + next_retry TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX wholesale_sync_log_brand_idx ON wholesale_sync_log (brand_id); + +-- ============================================================================ +-- 10. Water Log +-- ============================================================================ + +CREATE TABLE water_headgates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + name TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE water_irrigators ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + name TEXT NOT NULL, + pin_hash TEXT NOT NULL, + language_preference TEXT NOT NULL DEFAULT 'en', + active BOOLEAN NOT NULL DEFAULT true, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE water_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + irrigator_id UUID NOT NULL REFERENCES water_irrigators(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE water_log_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + headgate_id UUID NOT NULL REFERENCES water_headgates(id) ON DELETE CASCADE, + irrigator_id UUID NOT NULL REFERENCES water_irrigators(id) ON DELETE CASCADE, + measurement NUMERIC NOT NULL, + unit TEXT NOT NULL, + notes TEXT, + submitted_via TEXT NOT NULL DEFAULT 'app', + logged_at TIMESTAMPTZ NOT NULL DEFAULT now(), + logged_by UUID REFERENCES admin_users(id) +); + +CREATE INDEX water_log_entries_brand_idx ON water_log_entries (brand_id); +CREATE INDEX water_log_entries_headgate_idx ON water_log_entries (headgate_id); + +CREATE TABLE water_alert_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + alert_type TEXT NOT NULL, + headgate_id UUID REFERENCES water_headgates(id) ON DELETE SET NULL, + message TEXT NOT NULL, + sent_to TEXT, + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ============================================================================ +-- 11. Communications (Harvest Reach) +-- ============================================================================ + +CREATE TABLE communication_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, + default_sender_email TEXT, + default_sender_name TEXT, + reply_to_email TEXT, + email_provider TEXT NOT NULL DEFAULT 'resend', + email_footer_html TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER communication_settings_updated_at BEFORE UPDATE ON communication_settings + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE communication_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + name TEXT NOT NULL, + subject TEXT NOT NULL, + body_text TEXT NOT NULL DEFAULT '', + body_html TEXT, + template_type TEXT NOT NULL, + campaign_type TEXT, + created_by UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER communication_templates_updated_at BEFORE UPDATE ON communication_templates + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX communication_templates_brand_idx ON communication_templates (brand_id); + +CREATE TABLE communication_segments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + rules JSONB NOT NULL DEFAULT '{}', + created_by UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX communication_segments_brand_idx ON communication_segments (brand_id); + +CREATE TABLE communication_campaigns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + name TEXT NOT NULL, + subject TEXT, + body_text TEXT, + body_html TEXT, + template_id UUID REFERENCES communication_templates(id) ON DELETE SET NULL, + campaign_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'canceled')), + audience_rules JSONB NOT NULL DEFAULT '{}', + brand_name TEXT, + scheduled_at TIMESTAMPTZ, + sent_at TIMESTAMPTZ, + recipient_count INTEGER NOT NULL DEFAULT 0, + created_by UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER communication_campaigns_updated_at BEFORE UPDATE ON communication_campaigns + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX communication_campaigns_brand_idx ON communication_campaigns (brand_id); +CREATE INDEX communication_campaigns_status_idx ON communication_campaigns (brand_id, status); + +CREATE TABLE communication_contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + email TEXT, + phone TEXT, + first_name TEXT, + last_name TEXT, + full_name TEXT, + source TEXT NOT NULL, + external_id TEXT, + customer_id UUID, + email_opt_in BOOLEAN NOT NULL DEFAULT true, + sms_opt_in BOOLEAN NOT NULL DEFAULT false, + email_opt_in_at TIMESTAMPTZ, + sms_opt_in_at TIMESTAMPTZ, + unsubscribed_at TIMESTAMPTZ, + tags TEXT[] DEFAULT '{}', + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_has_email_or_phone CHECK (email IS NOT NULL OR phone IS NOT NULL) +); + +CREATE TRIGGER communication_contacts_updated_at BEFORE UPDATE ON communication_contacts + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX communication_contacts_brand_idx ON communication_contacts (brand_id); +CREATE INDEX communication_contacts_email_idx ON communication_contacts (brand_id, email); + +CREATE TABLE communication_message_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + campaign_id UUID REFERENCES communication_campaigns(id) ON DELETE SET NULL, + contact_id UUID REFERENCES communication_contacts(id) ON DELETE SET NULL, + customer_email TEXT, + delivery_method TEXT NOT NULL, + subject TEXT, + body_preview TEXT, + status TEXT NOT NULL, + sent_at TIMESTAMPTZ, + error_message TEXT, + event_type TEXT, + event_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX communication_message_logs_brand_idx ON communication_message_logs (brand_id); +CREATE INDEX communication_message_logs_campaign_idx ON communication_message_logs (campaign_id); + +CREATE TABLE customer_communication_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID NOT NULL, + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + email_opt_in BOOLEAN NOT NULL DEFAULT true, + sms_opt_in BOOLEAN NOT NULL DEFAULT false, + email_opt_in_at TIMESTAMPTZ, + sms_opt_in_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (customer_id, brand_id) +); + +CREATE TRIGGER customer_communication_preferences_updated_at BEFORE UPDATE ON customer_communication_preferences + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Email automation sequences +CREATE TABLE abandoned_cart_recovery ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + customer_id UUID REFERENCES wholesale_customers(id) ON DELETE SET NULL, + contact_email TEXT NOT NULL, + contact_name TEXT, + cart_snapshot JSONB NOT NULL, + brand_name TEXT, + locale TEXT DEFAULT 'en', + sequence_step INTEGER DEFAULT 0, + last_email_sent_at TIMESTAMPTZ, + next_email_at TIMESTAMPTZ, + status TEXT DEFAULT 'active' + CHECK (status IN ('active', 'recovered', 'expired', 'manually_closed')), + recovered_order_id UUID, + recovered_at TIMESTAMPTZ, + expired_at TIMESTAMPTZ, + manually_closed_at TIMESTAMPTZ, + manually_closed_by UUID REFERENCES admin_users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER abandoned_cart_recovery_updated_at BEFORE UPDATE ON abandoned_cart_recovery + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE welcome_email_sequence ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + contact_id UUID REFERENCES communication_contacts(id) ON DELETE CASCADE, + contact_email TEXT NOT NULL, + contact_name TEXT, + brand_name TEXT, + locale TEXT DEFAULT 'en', + sequence_step INTEGER DEFAULT 0, + last_email_sent_at TIMESTAMPTZ, + next_email_at TIMESTAMPTZ, + status TEXT DEFAULT 'active' + CHECK (status IN ('active', 'completed', 'unsubscribed', 'bounced')), + completed_at TIMESTAMPTZ, + unsubscribed_at TIMESTAMPTZ, + bounced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER welcome_email_sequence_updated_at BEFORE UPDATE ON welcome_email_sequence + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- ============================================================================ +-- 12. Shipping + Payments +-- ============================================================================ + +CREATE TABLE shipping_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, + carrier TEXT NOT NULL DEFAULT 'fedex', + fedex_account_number TEXT, + fedex_api_key TEXT, + fedex_api_secret TEXT, + fedex_use_production BOOLEAN NOT NULL DEFAULT false, + default_service_type TEXT NOT NULL DEFAULT 'FEDEX_GROUND', + refrigerated_handling_notes TEXT, + fragile_handling_notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER shipping_settings_updated_at BEFORE UPDATE ON shipping_settings + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE shipments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + carrier TEXT NOT NULL DEFAULT 'fedex', + service_type TEXT NOT NULL, + tracking_number TEXT, + label_url TEXT, + rate_charged NUMERIC(10,2), + estimated_delivery_date DATE, + is_refrigerated BOOLEAN NOT NULL DEFAULT false, + is_fragile BOOLEAN NOT NULL DEFAULT false, + handling_notes TEXT, + status TEXT NOT NULL DEFAULT 'created' + CHECK (status IN ('created', 'label_printed', 'picked_up', 'in_transit', 'delivered', 'exception')), + fedex_shipment_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES admin_users(id) +); + +CREATE TRIGGER shipments_updated_at BEFORE UPDATE ON shipments + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX shipments_order_idx ON shipments (order_id); + +CREATE TABLE payment_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, + provider TEXT, + stripe_publishable_key TEXT, + stripe_secret_key TEXT, + square_access_token TEXT, + square_location_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER payment_settings_updated_at BEFORE UPDATE ON payment_settings + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- ============================================================================ +-- 13. Square Sync +-- ============================================================================ + +CREATE TABLE square_sync_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + sync_type TEXT NOT NULL DEFAULT 'products' + CHECK (sync_type IN ('products', 'inventory', 'orders')), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'processing', 'done', 'failed')), + enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + processed_at TIMESTAMPTZ, + retry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT +); + +CREATE INDEX square_sync_queue_brand_idx ON square_sync_queue (brand_id, status); + +CREATE TABLE square_sync_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + sync_type TEXT NOT NULL, + direction TEXT NOT NULL CHECK (direction IN ('import', 'export')), + item_id TEXT, + status TEXT NOT NULL, + message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX square_sync_log_brand_idx ON square_sync_log (brand_id); + +-- ============================================================================ +-- 14. Time Tracking +-- ============================================================================ + +CREATE TABLE time_tracking_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE, + pay_period_start_day INTEGER NOT NULL DEFAULT 0, + pay_period_length_days INTEGER NOT NULL DEFAULT 7, + daily_overtime_threshold NUMERIC(5,2) NOT NULL DEFAULT 8.0, + weekly_overtime_threshold NUMERIC(5,2) NOT NULL DEFAULT 40.0, + overtime_multiplier NUMERIC(3,2) NOT NULL DEFAULT 1.50, + overtime_notifications BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER time_tracking_settings_updated_at BEFORE UPDATE ON time_tracking_settings + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE time_tracking_workers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK (role IN ('worker', 'time_admin')), + lang TEXT NOT NULL DEFAULT 'en', + pin TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX time_tracking_workers_brand_idx ON time_tracking_workers (brand_id); + +CREATE TABLE time_tracking_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + name TEXT NOT NULL, + name_es TEXT, + unit TEXT NOT NULL DEFAULT 'hours' + CHECK (unit IN ('hours', 'pieces', 'units')), + sort_order INTEGER NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX time_tracking_tasks_brand_idx ON time_tracking_tasks (brand_id); + +CREATE TABLE time_tracking_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + worker_id UUID NOT NULL REFERENCES time_tracking_workers(id) ON DELETE CASCADE, + task_id UUID REFERENCES time_tracking_tasks(id) ON DELETE SET NULL, + task_name TEXT NOT NULL, + clock_in TIMESTAMPTZ NOT NULL, + clock_out TIMESTAMPTZ, + lunch_break_minutes INTEGER NOT NULL DEFAULT 0, + notes TEXT, + submitted_via TEXT NOT NULL DEFAULT 'manual' + CHECK (submitted_via IN ('manual', 'field', 'import')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX time_tracking_logs_brand_idx ON time_tracking_logs (brand_id); +CREATE INDEX time_tracking_logs_worker_idx ON time_tracking_logs (worker_id); +CREATE INDEX time_tracking_logs_clock_in_idx ON time_tracking_logs (clock_in); + +CREATE TABLE time_tracking_notification_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + worker_id UUID REFERENCES time_tracking_workers(id) ON DELETE SET NULL, + notification_type TEXT NOT NULL, + recipient TEXT NOT NULL, + subject TEXT, + body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'sent', 'failed')), + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ============================================================================ +-- 15. Operational + Audit +-- ============================================================================ + +CREATE TABLE operational_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + entity_type TEXT, + entity_id UUID, + actor_type TEXT, + actor_id UUID, + source TEXT NOT NULL DEFAULT 'system', + payload JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX operational_events_brand_idx ON operational_events (brand_id); +CREATE INDEX operational_events_type_idx ON operational_events (brand_id, event_type); + +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID, + brand_id UUID REFERENCES brands(id) ON DELETE SET NULL, + action TEXT NOT NULL, + entity_type TEXT, + entity_id UUID, + details JSONB, + ip_address TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX audit_logs_brand_idx ON audit_logs (brand_id); +CREATE INDEX audit_logs_user_idx ON audit_logs (user_id); +CREATE INDEX audit_logs_created_idx ON audit_logs (created_at DESC); + +CREATE TABLE admin_action_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + admin_user_id UUID NOT NULL REFERENCES admin_users(id), + brand_id UUID REFERENCES brands(id) ON DELETE SET NULL, + action TEXT NOT NULL, + target_type TEXT, + target_id UUID, + metadata JSONB, + ip_address TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX admin_action_logs_admin_idx ON admin_action_logs (admin_user_id); +CREATE INDEX admin_action_logs_created_idx ON admin_action_logs (created_at DESC); + +-- ============================================================================ +-- 16. Support Tables +-- ============================================================================ + +CREATE TABLE user_carts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE, + items JSONB NOT NULL DEFAULT '[]', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER user_carts_updated_at BEFORE UPDATE ON user_carts + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE UNIQUE INDEX user_carts_customer_idx ON user_carts (brand_id, customer_id); + +CREATE TABLE referral_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, + referrer_user_id UUID NOT NULL, + referral_code VARCHAR(50) UNIQUE NOT NULL, + referrer_email VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ DEFAULT now() + INTERVAL '1 year', + max_uses INTEGER DEFAULT 1, + current_uses INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + reward_type VARCHAR(50) DEFAULT 'percentage', + reward_value DECIMAL(10,2) DEFAULT 20.00, + metadata JSONB DEFAULT '{}' +); + +CREATE TABLE referral_redemptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + referral_code_id UUID REFERENCES referral_codes(id) ON DELETE CASCADE, + brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, + referred_user_id UUID NOT NULL, + referred_email VARCHAR(255) NOT NULL, + redeemed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + reward_type VARCHAR(50), + reward_value DECIMAL(10,2), + is_credit_applied BOOLEAN DEFAULT false, + signup_plan VARCHAR(50), + signup_value DECIMAL(10,2), + metadata JSONB DEFAULT '{}' +); + +CREATE TABLE changelogs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, + version VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + content JSONB NOT NULL DEFAULT '[]', + released_at TIMESTAMPTZ NOT NULL DEFAULT now(), + is_published BOOLEAN DEFAULT false, + feature_type VARCHAR(50) DEFAULT 'general', + metadata JSONB DEFAULT '{}', + UNIQUE (brand_id, version) +); + +CREATE TABLE changelog_reads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + changelog_id UUID REFERENCES changelogs(id) ON DELETE CASCADE, + read_at TIMESTAMPTZ NOT NULL DEFAULT now(), + dismissed BOOLEAN DEFAULT false, + UNIQUE (user_id, changelog_id) +); + +CREATE TABLE onboarding_progress ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + current_step VARCHAR(50) NOT NULL, + completed_steps JSONB DEFAULT '[]', + skipped_steps JSONB DEFAULT '[]', + data JSONB DEFAULT '{}', + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + is_completed BOOLEAN DEFAULT false, + metadata JSONB DEFAULT '{}', + UNIQUE (brand_id, user_id) +); + +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + key_hash VARCHAR(255) NOT NULL, + key_prefix VARCHAR(20), + permissions JSONB DEFAULT '["read"]', + rate_limit INTEGER DEFAULT 1000, + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID NOT NULL +); + +CREATE INDEX api_keys_brand_idx ON api_keys (brand_id); + +CREATE TABLE notification_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, + email_orders BOOLEAN DEFAULT true, + email_marketing BOOLEAN DEFAULT false, + email_reports BOOLEAN DEFAULT true, + email_billing BOOLEAN DEFAULT true, + sms_orders BOOLEAN DEFAULT false, + sms_marketing BOOLEAN DEFAULT false, + push_orders BOOLEAN DEFAULT true, + push_marketing BOOLEAN DEFAULT false, + quiet_hours_start TIME, + quiet_hours_end TIME, + timezone VARCHAR(50) DEFAULT 'America/New_York', + metadata JSONB DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, brand_id) +); + +-- ============================================================================ +-- 17. Files + Audit Log +-- ============================================================================ + +CREATE TABLE files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + storage_key TEXT NOT NULL UNIQUE, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + purpose TEXT, + uploaded_by UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX files_brand_idx ON files (brand_id); + +CREATE TABLE audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID REFERENCES brands(id) ON DELETE CASCADE, + user_id UUID, action TEXT NOT NULL, target_type TEXT, target_id UUID, payload JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE INDEX audit_log_brand_idx ON audit_log (brand_id, created_at); -CREATE INDEX IF NOT EXISTS audit_log_tenant_idx - ON audit_log (tenant_id, created_at DESC); +-- ============================================================================ +-- 18. GUC Helpers + RLS +-- ============================================================================ --- ════════════════════════════════════════════════════════════════════════ --- 11. RLS helpers --- ════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION current_tenant_id() -RETURNS UUID -LANGUAGE sql -STABLE -AS $$ - SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::UUID; +CREATE OR REPLACE FUNCTION current_brand_id() RETURNS UUID +LANGUAGE sql STABLE AS $$ + SELECT NULLIF(current_setting('app.current_brand_id', true), '')::UUID; $$; -CREATE OR REPLACE FUNCTION is_platform_admin() -RETURNS BOOLEAN -LANGUAGE sql -STABLE -AS $$ +CREATE OR REPLACE FUNCTION is_platform_admin() RETURNS BOOLEAN +LANGUAGE sql STABLE AS $$ SELECT COALESCE(NULLIF(current_setting('app.platform_admin', true), ''), 'false') = 'true'; $$; --- ════════════════════════════════════════════════════════════════════════ --- 12. Enable RLS on tenant-scoped tables --- ════════════════════════════════════════════════════════════════════════ - +-- Enable RLS on brand-scoped tables ALTER TABLE products ENABLE ROW LEVEL SECURITY; -ALTER TABLE product_images ENABLE ROW LEVEL SECURITY; ALTER TABLE stops ENABLE ROW LEVEL SECURITY; +ALTER TABLE locations ENABLE ROW LEVEL SECURITY; ALTER TABLE customers ENABLE ROW LEVEL SECURITY; ALTER TABLE orders ENABLE ROW LEVEL SECURITY; ALTER TABLE order_items ENABLE ROW LEVEL SECURITY; ALTER TABLE brand_settings ENABLE ROW LEVEL SECURITY; -ALTER TABLE email_templates ENABLE ROW LEVEL SECURITY; -ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY; +ALTER TABLE brand_features ENABLE ROW LEVEL SECURITY; +ALTER TABLE wholesale_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE wholesale_customers ENABLE ROW LEVEL SECURITY; +ALTER TABLE wholesale_products ENABLE ROW LEVEL SECURITY; +ALTER TABLE wholesale_orders ENABLE ROW LEVEL SECURITY; +ALTER TABLE wholesale_order_items ENABLE ROW LEVEL SECURITY; +ALTER TABLE wholesale_deposits ENABLE ROW LEVEL SECURITY; +ALTER TABLE wholesale_notifications ENABLE ROW LEVEL SECURITY; +ALTER TABLE wholesale_customer_product_pricing ENABLE ROW LEVEL SECURITY; +ALTER TABLE wholesale_webhook_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE wholesale_sync_log ENABLE ROW LEVEL SECURITY; +ALTER TABLE water_headgates ENABLE ROW LEVEL SECURITY; +ALTER TABLE water_irrigators ENABLE ROW LEVEL SECURITY; +ALTER TABLE water_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE water_log_entries ENABLE ROW LEVEL SECURITY; +ALTER TABLE water_alert_log ENABLE ROW LEVEL SECURITY; +ALTER TABLE communication_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE communication_templates ENABLE ROW LEVEL SECURITY; +ALTER TABLE communication_segments ENABLE ROW LEVEL SECURITY; +ALTER TABLE communication_campaigns ENABLE ROW LEVEL SECURITY; +ALTER TABLE communication_contacts ENABLE ROW LEVEL SECURITY; +ALTER TABLE communication_message_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE customer_communication_preferences ENABLE ROW LEVEL SECURITY; +ALTER TABLE abandoned_cart_recovery ENABLE ROW LEVEL SECURITY; +ALTER TABLE welcome_email_sequence ENABLE ROW LEVEL SECURITY; +ALTER TABLE shipping_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE shipments ENABLE ROW LEVEL SECURITY; +ALTER TABLE payment_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE square_sync_queue ENABLE ROW LEVEL SECURITY; +ALTER TABLE square_sync_log ENABLE ROW LEVEL SECURITY; +ALTER TABLE time_tracking_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE time_tracking_workers ENABLE ROW LEVEL SECURITY; +ALTER TABLE time_tracking_tasks ENABLE ROW LEVEL SECURITY; +ALTER TABLE time_tracking_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE time_tracking_notification_log ENABLE ROW LEVEL SECURITY; +ALTER TABLE operational_events ENABLE ROW LEVEL SECURITY; +ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE admin_action_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_carts ENABLE ROW LEVEL SECURITY; +ALTER TABLE referral_codes ENABLE ROW LEVEL SECURITY; +ALTER TABLE referral_redemptions ENABLE ROW LEVEL SECURITY; +ALTER TABLE changelogs ENABLE ROW LEVEL SECURITY; +ALTER TABLE changelog_reads ENABLE ROW LEVEL SECURITY; +ALTER TABLE onboarding_progress ENABLE ROW LEVEL SECURITY; +ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; +ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY; ALTER TABLE files ENABLE ROW LEVEL SECURITY; ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY; --- RLS does not apply to the table owner by default. We FORCE it so the --- application (which connects as the same user that owns the tables in --- dev) cannot accidentally bypass tenant isolation. In production, the --- application should connect as a non-owner role; this is belt-and- --- suspenders for the dev/local case. +-- Force RLS (app connects as table owner in dev; belt-and-suspenders) ALTER TABLE products FORCE ROW LEVEL SECURITY; -ALTER TABLE product_images FORCE ROW LEVEL SECURITY; ALTER TABLE stops FORCE ROW LEVEL SECURITY; +ALTER TABLE locations FORCE ROW LEVEL SECURITY; ALTER TABLE customers FORCE ROW LEVEL SECURITY; ALTER TABLE orders FORCE ROW LEVEL SECURITY; ALTER TABLE order_items FORCE ROW LEVEL SECURITY; ALTER TABLE brand_settings FORCE ROW LEVEL SECURITY; -ALTER TABLE email_templates FORCE ROW LEVEL SECURITY; -ALTER TABLE campaigns FORCE ROW LEVEL SECURITY; +ALTER TABLE brand_features FORCE ROW LEVEL SECURITY; +ALTER TABLE wholesale_settings FORCE ROW LEVEL SECURITY; +ALTER TABLE wholesale_customers FORCE ROW LEVEL SECURITY; +ALTER TABLE wholesale_products FORCE ROW LEVEL SECURITY; +ALTER TABLE wholesale_orders FORCE ROW LEVEL SECURITY; +ALTER TABLE wholesale_order_items FORCE ROW LEVEL SECURITY; +ALTER TABLE wholesale_deposits FORCE ROW LEVEL SECURITY; +ALTER TABLE wholesale_notifications FORCE ROW LEVEL SECURITY; +ALTER TABLE wholesale_customer_product_pricing FORCE ROW LEVEL SECURITY; +ALTER TABLE wholesale_webhook_settings FORCE ROW LEVEL SECURITY; +ALTER TABLE wholesale_sync_log FORCE ROW LEVEL SECURITY; +ALTER TABLE water_headgates FORCE ROW LEVEL SECURITY; +ALTER TABLE water_irrigators FORCE ROW LEVEL SECURITY; +ALTER TABLE water_sessions FORCE ROW LEVEL SECURITY; +ALTER TABLE water_log_entries FORCE ROW LEVEL SECURITY; +ALTER TABLE water_alert_log FORCE ROW LEVEL SECURITY; +ALTER TABLE communication_settings FORCE ROW LEVEL SECURITY; +ALTER TABLE communication_templates FORCE ROW LEVEL SECURITY; +ALTER TABLE communication_segments FORCE ROW LEVEL SECURITY; +ALTER TABLE communication_campaigns FORCE ROW LEVEL SECURITY; +ALTER TABLE communication_contacts FORCE ROW LEVEL SECURITY; +ALTER TABLE communication_message_logs FORCE ROW LEVEL SECURITY; +ALTER TABLE customer_communication_preferences FORCE ROW LEVEL SECURITY; +ALTER TABLE abandoned_cart_recovery FORCE ROW LEVEL SECURITY; +ALTER TABLE welcome_email_sequence FORCE ROW LEVEL SECURITY; +ALTER TABLE shipping_settings FORCE ROW LEVEL SECURITY; +ALTER TABLE shipments FORCE ROW LEVEL SECURITY; +ALTER TABLE payment_settings FORCE ROW LEVEL SECURITY; +ALTER TABLE square_sync_queue FORCE ROW LEVEL SECURITY; +ALTER TABLE square_sync_log FORCE ROW LEVEL SECURITY; +ALTER TABLE time_tracking_settings FORCE ROW LEVEL SECURITY; +ALTER TABLE time_tracking_workers FORCE ROW LEVEL SECURITY; +ALTER TABLE time_tracking_tasks FORCE ROW LEVEL SECURITY; +ALTER TABLE time_tracking_logs FORCE ROW LEVEL SECURITY; +ALTER TABLE time_tracking_notification_log FORCE ROW LEVEL SECURITY; +ALTER TABLE operational_events FORCE ROW LEVEL SECURITY; +ALTER TABLE audit_logs FORCE ROW LEVEL SECURITY; +ALTER TABLE admin_action_logs FORCE ROW LEVEL SECURITY; +ALTER TABLE user_carts FORCE ROW LEVEL SECURITY; +ALTER TABLE referral_codes FORCE ROW LEVEL SECURITY; +ALTER TABLE referral_redemptions FORCE ROW LEVEL SECURITY; +ALTER TABLE changelogs FORCE ROW LEVEL SECURITY; +ALTER TABLE changelog_reads FORCE ROW LEVEL SECURITY; +ALTER TABLE onboarding_progress FORCE ROW LEVEL SECURITY; +ALTER TABLE api_keys FORCE ROW LEVEL SECURITY; +ALTER TABLE notification_preferences FORCE ROW LEVEL SECURITY; ALTER TABLE files FORCE ROW LEVEL SECURITY; ALTER TABLE audit_log FORCE ROW LEVEL SECURITY; --- ════════════════════════════════════════════════════════════════════════ --- 13. RLS policies: tenant match OR platform admin --- ════════════════════════════════════════════════════════════════════════ +-- RLS policies: brand match OR platform admin +-- Macro: FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) --- Drop existing policies (idempotent — same migration may be re-applied) +-- products DROP POLICY IF EXISTS tenant_isolation ON products; -DROP POLICY IF EXISTS tenant_isolation ON product_images; +CREATE POLICY tenant_isolation ON products FOR ALL + USING (brand_id = current_brand_id() OR is_platform_admin()) + WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- stops DROP POLICY IF EXISTS tenant_isolation ON stops; +CREATE POLICY tenant_isolation ON stops FOR ALL + USING (brand_id = current_brand_id() OR is_platform_admin()) + WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- locations +DROP POLICY IF EXISTS tenant_isolation ON locations; +CREATE POLICY tenant_isolation ON locations FOR ALL + USING (brand_id = current_brand_id() OR is_platform_admin()) + WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- customers DROP POLICY IF EXISTS tenant_isolation ON customers; +CREATE POLICY tenant_isolation ON customers FOR ALL + USING (brand_id = current_brand_id() OR is_platform_admin()) + WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- orders DROP POLICY IF EXISTS tenant_isolation ON orders; +CREATE POLICY tenant_isolation ON orders FOR ALL + USING (brand_id = current_brand_id() OR is_platform_admin()) + WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- order_items (via orders) DROP POLICY IF EXISTS tenant_isolation ON order_items; +CREATE POLICY tenant_isolation ON order_items FOR ALL + USING (EXISTS (SELECT 1 FROM orders o WHERE o.id = order_items.order_id AND (o.brand_id = current_brand_id() OR is_platform_admin()))) + WITH CHECK (EXISTS (SELECT 1 FROM orders o WHERE o.id = order_items.order_id AND (o.brand_id = current_brand_id() OR is_platform_admin()))); + +-- brand_settings DROP POLICY IF EXISTS tenant_isolation ON brand_settings; -DROP POLICY IF EXISTS tenant_isolation ON email_templates; -DROP POLICY IF EXISTS tenant_isolation ON campaigns; +CREATE POLICY tenant_isolation ON brand_settings FOR ALL + USING (brand_id = current_brand_id() OR is_platform_admin()) + WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- brand_features +DROP POLICY IF EXISTS tenant_isolation ON brand_features; +CREATE POLICY tenant_isolation ON brand_features FOR ALL + USING (brand_id = current_brand_id() OR is_platform_admin()) + WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- wholesale_* tables +DROP POLICY IF EXISTS tenant_isolation ON wholesale_settings; +CREATE POLICY tenant_isolation ON wholesale_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON wholesale_customers; +CREATE POLICY tenant_isolation ON wholesale_customers FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON wholesale_products; +CREATE POLICY tenant_isolation ON wholesale_products FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON wholesale_orders; +CREATE POLICY tenant_isolation ON wholesale_orders FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON wholesale_order_items; +CREATE POLICY tenant_isolation ON wholesale_order_items FOR ALL USING (EXISTS (SELECT 1 FROM wholesale_orders wo WHERE wo.id = wholesale_order_items.wholesale_order_id AND (wo.brand_id = current_brand_id() OR is_platform_admin()))) WITH CHECK (EXISTS (SELECT 1 FROM wholesale_orders wo WHERE wo.id = wholesale_order_items.wholesale_order_id AND (wo.brand_id = current_brand_id() OR is_platform_admin()))); + +DROP POLICY IF EXISTS tenant_isolation ON wholesale_deposits; +CREATE POLICY tenant_isolation ON wholesale_deposits FOR ALL USING (EXISTS (SELECT 1 FROM wholesale_orders wo WHERE wo.id = wholesale_deposits.wholesale_order_id AND (wo.brand_id = current_brand_id() OR is_platform_admin()))) WITH CHECK (EXISTS (SELECT 1 FROM wholesale_orders wo WHERE wo.id = wholesale_deposits.wholesale_order_id AND (wo.brand_id = current_brand_id() OR is_platform_admin()))); + +DROP POLICY IF EXISTS tenant_isolation ON wholesale_notifications; +CREATE POLICY tenant_isolation ON wholesale_notifications FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON wholesale_customer_product_pricing; +CREATE POLICY tenant_isolation ON wholesale_customer_product_pricing FOR ALL USING (EXISTS (SELECT 1 FROM wholesale_customers wc WHERE wc.id = wholesale_customer_product_pricing.customer_id AND (wc.brand_id = current_brand_id() OR is_platform_admin()))) WITH CHECK (EXISTS (SELECT 1 FROM wholesale_customers wc WHERE wc.id = wholesale_customer_product_pricing.customer_id AND (wc.brand_id = current_brand_id() OR is_platform_admin()))); + +DROP POLICY IF EXISTS tenant_isolation ON wholesale_webhook_settings; +CREATE POLICY tenant_isolation ON wholesale_webhook_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON wholesale_sync_log; +CREATE POLICY tenant_isolation ON wholesale_sync_log FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- water_log +DROP POLICY IF EXISTS tenant_isolation ON water_headgates; +CREATE POLICY tenant_isolation ON water_headgates FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON water_irrigators; +CREATE POLICY tenant_isolation ON water_irrigators FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON water_sessions; +CREATE POLICY tenant_isolation ON water_sessions FOR ALL USING (EXISTS (SELECT 1 FROM water_irrigators wi WHERE wi.id = water_sessions.irrigator_id AND (wi.brand_id = current_brand_id() OR is_platform_admin()))) WITH CHECK (EXISTS (SELECT 1 FROM water_irrigators wi WHERE wi.id = water_sessions.irrigator_id AND (wi.brand_id = current_brand_id() OR is_platform_admin()))); + +DROP POLICY IF EXISTS tenant_isolation ON water_log_entries; +CREATE POLICY tenant_isolation ON water_log_entries FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON water_alert_log; +CREATE POLICY tenant_isolation ON water_alert_log FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- communications +DROP POLICY IF EXISTS tenant_isolation ON communication_settings; +CREATE POLICY tenant_isolation ON communication_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON communication_templates; +CREATE POLICY tenant_isolation ON communication_templates FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON communication_segments; +CREATE POLICY tenant_isolation ON communication_segments FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON communication_campaigns; +CREATE POLICY tenant_isolation ON communication_campaigns FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON communication_contacts; +CREATE POLICY tenant_isolation ON communication_contacts FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON communication_message_logs; +CREATE POLICY tenant_isolation ON communication_message_logs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON customer_communication_preferences; +CREATE POLICY tenant_isolation ON customer_communication_preferences FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON abandoned_cart_recovery; +CREATE POLICY tenant_isolation ON abandoned_cart_recovery FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON welcome_email_sequence; +CREATE POLICY tenant_isolation ON welcome_email_sequence FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- shipping +DROP POLICY IF EXISTS tenant_isolation ON shipping_settings; +CREATE POLICY tenant_isolation ON shipping_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON shipments; +CREATE POLICY tenant_isolation ON shipments FOR ALL USING (EXISTS (SELECT 1 FROM orders o WHERE o.id = shipments.order_id AND (o.brand_id = current_brand_id() OR is_platform_admin()))) WITH CHECK (EXISTS (SELECT 1 FROM orders o WHERE o.id = shipments.order_id AND (o.brand_id = current_brand_id() OR is_platform_admin()))); + +DROP POLICY IF EXISTS tenant_isolation ON payment_settings; +CREATE POLICY tenant_isolation ON payment_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- square +DROP POLICY IF EXISTS tenant_isolation ON square_sync_queue; +CREATE POLICY tenant_isolation ON square_sync_queue FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON square_sync_log; +CREATE POLICY tenant_isolation ON square_sync_log FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- time tracking +DROP POLICY IF EXISTS tenant_isolation ON time_tracking_settings; +CREATE POLICY tenant_isolation ON time_tracking_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON time_tracking_workers; +CREATE POLICY tenant_isolation ON time_tracking_workers FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON time_tracking_tasks; +CREATE POLICY tenant_isolation ON time_tracking_tasks FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON time_tracking_logs; +CREATE POLICY tenant_isolation ON time_tracking_logs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON time_tracking_notification_log; +CREATE POLICY tenant_isolation ON time_tracking_notification_log FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- audit +DROP POLICY IF EXISTS tenant_isolation ON operational_events; +CREATE POLICY tenant_isolation ON operational_events FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON audit_logs; +CREATE POLICY tenant_isolation ON audit_logs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL); + +DROP POLICY IF EXISTS tenant_isolation ON admin_action_logs; +CREATE POLICY tenant_isolation ON admin_action_logs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- support +DROP POLICY IF EXISTS tenant_isolation ON user_carts; +CREATE POLICY tenant_isolation ON user_carts FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +DROP POLICY IF EXISTS tenant_isolation ON referral_codes; +CREATE POLICY tenant_isolation ON referral_codes FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL); + +DROP POLICY IF EXISTS tenant_isolation ON referral_redemptions; +CREATE POLICY tenant_isolation ON referral_redemptions FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL); + +DROP POLICY IF EXISTS tenant_isolation ON changelogs; +CREATE POLICY tenant_isolation ON changelogs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL); + +DROP POLICY IF EXISTS tenant_isolation ON changelog_reads; +CREATE POLICY tenant_isolation ON changelog_reads FOR ALL USING (true) WITH CHECK (true); + +DROP POLICY IF EXISTS tenant_isolation ON onboarding_progress; +CREATE POLICY tenant_isolation ON onboarding_progress FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL); + +DROP POLICY IF EXISTS tenant_isolation ON api_keys; +CREATE POLICY tenant_isolation ON api_keys FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL); + +DROP POLICY IF EXISTS tenant_isolation ON notification_preferences; +CREATE POLICY tenant_isolation ON notification_preferences FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL); + DROP POLICY IF EXISTS tenant_isolation ON files; +CREATE POLICY tenant_isolation ON files FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL); + DROP POLICY IF EXISTS tenant_isolation ON audit_log; +CREATE POLICY tenant_isolation ON audit_log FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL); -CREATE POLICY tenant_isolation ON products - FOR ALL - USING (tenant_id = current_tenant_id() OR is_platform_admin()) - WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin()); +-- ============================================================================ +-- 19. Key RPC Functions +-- SECURITY DEFINER so they always run with table-owner privileges. +-- ============================================================================ -CREATE POLICY tenant_isolation ON product_images - FOR ALL - USING ( - EXISTS ( - SELECT 1 FROM products p - WHERE p.id = product_images.product_id - AND (p.tenant_id = current_tenant_id() OR is_platform_admin()) - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM products p - WHERE p.id = product_images.product_id - AND (p.tenant_id = current_tenant_id() OR is_platform_admin()) - ) - ); - -CREATE POLICY tenant_isolation ON stops - FOR ALL - USING (tenant_id = current_tenant_id() OR is_platform_admin()) - WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin()); - -CREATE POLICY tenant_isolation ON customers - FOR ALL - USING (tenant_id = current_tenant_id() OR is_platform_admin()) - WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin()); - -CREATE POLICY tenant_isolation ON orders - FOR ALL - USING (tenant_id = current_tenant_id() OR is_platform_admin()) - WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin()); - -CREATE POLICY tenant_isolation ON order_items - FOR ALL - USING ( - EXISTS ( - SELECT 1 FROM orders o - WHERE o.id = order_items.order_id - AND (o.tenant_id = current_tenant_id() OR is_platform_admin()) - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 FROM orders o - WHERE o.id = order_items.order_id - AND (o.tenant_id = current_tenant_id() OR is_platform_admin()) - ) - ); - -CREATE POLICY tenant_isolation ON brand_settings - FOR ALL - USING (tenant_id = current_tenant_id() OR is_platform_admin()) - WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin()); - -CREATE POLICY tenant_isolation ON email_templates - FOR ALL - USING (tenant_id = current_tenant_id() OR is_platform_admin()) - WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin()); - -CREATE POLICY tenant_isolation ON campaigns - FOR ALL - USING (tenant_id = current_tenant_id() OR is_platform_admin()) - WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin()); - -CREATE POLICY tenant_isolation ON files - FOR ALL - USING (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL) - WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL); - -CREATE POLICY tenant_isolation ON audit_log - FOR ALL - USING (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL) - WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL); - --- ════════════════════════════════════════════════════════════════════════ --- 14. updated_at triggers --- ════════════════════════════════════════════════════════════════════════ - -CREATE OR REPLACE FUNCTION set_updated_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ +-- set_brand_feature: enable/disable a feature flag +CREATE OR REPLACE FUNCTION set_brand_feature(p_brand_id UUID, p_feature_key TEXT, p_enabled BOOLEAN) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN - NEW.updated_at = now(); - RETURN NEW; + INSERT INTO brand_features (brand_id, feature_key, enabled, enabled_at, disabled_at) + VALUES (p_brand_id, p_feature_key, p_enabled, + CASE WHEN p_enabled THEN now() ELSE NULL END, + CASE WHEN NOT p_enabled THEN now() ELSE NULL END) + ON CONFLICT (brand_id, feature_key) DO UPDATE SET + enabled = p_enabled, + enabled_at = CASE WHEN p_enabled THEN now() ELSE brand_features.enabled_at END, + disabled_at = CASE WHEN NOT p_enabled THEN now() ELSE brand_features.disabled_at END; END; $$; -DROP TRIGGER IF EXISTS tenants_updated_at ON tenants; -CREATE TRIGGER tenants_updated_at BEFORE UPDATE ON tenants - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- set_brand_subscription: save Stripe subscription info +CREATE OR REPLACE FUNCTION set_brand_subscription( + p_brand_id UUID, + p_stripe_subscription_id TEXT, + p_status TEXT, + p_current_period_end TIMESTAMPTZ +) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + UPDATE brands SET + stripe_subscription_id = p_stripe_subscription_id, + stripe_subscription_status = p_status, + stripe_current_period_end = p_current_period_end, + updated_at = now() + WHERE id = p_brand_id; +END; +$$; -DROP TRIGGER IF EXISTS users_updated_at ON users; -CREATE TRIGGER users_updated_at BEFORE UPDATE ON users - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- update_brand_plan_tier: update plan tier + limits +CREATE OR REPLACE FUNCTION update_brand_plan_tier( + p_brand_id UUID, + p_plan_tier TEXT, + p_max_users INTEGER, + p_max_products INTEGER, + p_max_stops_monthly INTEGER +) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + UPDATE brands SET + plan_tier = p_plan_tier, + max_users = p_max_users, + max_products = p_max_products, + max_stops_monthly = p_max_stops_monthly, + updated_at = now() + WHERE id = p_brand_id; +END; +$$; -DROP TRIGGER IF EXISTS subscriptions_updated_at ON subscriptions; -CREATE TRIGGER subscriptions_updated_at BEFORE UPDATE ON subscriptions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- enqueue_notification: add a notification to the queue +CREATE OR REPLACE FUNCTION enqueue_notification( + p_brand_id UUID, + p_customer_id UUID, + p_notification_type TEXT, + p_channel TEXT, + p_recipient TEXT, + p_subject TEXT, + p_body TEXT +) RETURNS UUID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + v_id UUID; +BEGIN + INSERT INTO wholesale_notifications + (brand_id, customer_id, notification_type, channel, recipient, subject, body) + VALUES + (p_brand_id, p_customer_id, p_notification_type, p_channel, p_recipient, p_subject, p_body) + RETURNING id INTO v_id; + RETURN v_id; +END; +$$; -DROP TRIGGER IF EXISTS products_updated_at ON products; -CREATE TRIGGER products_updated_at BEFORE UPDATE ON products - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- upsert_admin_user_for_neon_auth: provision admin_users from Neon Auth user +CREATE OR REPLACE FUNCTION upsert_admin_user_for_neon_auth(p_user_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + v_email TEXT; + v_name TEXT; +BEGIN + SELECT u.email, u.name INTO v_email, v_name + FROM neon_auth.user u WHERE u.id = p_user_id; + IF v_email IS NULL THEN RETURN; END IF; + INSERT INTO admin_users (user_id, email, name, role, + can_manage_orders, can_manage_products, can_manage_stops, + can_manage_customers, can_manage_wholesale, can_manage_billing, + can_manage_settings, can_manage_water_log, can_manage_time_tracking, + can_manage_route_trace, can_manage_reports, can_manage_communications) + VALUES (p_user_id, v_email, v_name, 'platform_admin', + true, true, true, true, true, true, true, true, true, true, true, true) + ON CONFLICT (user_id) DO NOTHING; +END; +$$; -DROP TRIGGER IF EXISTS stops_updated_at ON stops; -CREATE TRIGGER stops_updated_at BEFORE UPDATE ON stops - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- get_brand_plan_info: compute current usage vs plan limits +CREATE OR REPLACE FUNCTION get_brand_plan_info(p_brand_id UUID) +RETURNS TABLE ( + plan_tier TEXT, + max_users INTEGER, + max_products INTEGER, + max_stops_monthly INTEGER, + current_users INTEGER, + current_products INTEGER, + current_stops_this_month INTEGER +) LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + RETURN QUERY + SELECT + b.plan_tier, + b.max_users, + b.max_products, + b.max_stops_monthly, + COALESCE((SELECT COUNT(*) FROM admin_user_brands aub WHERE aub.brand_id = p_brand_id), 0)::INTEGER, + COALESCE((SELECT COUNT(*) FROM products p WHERE p.brand_id = p_brand_id AND p.active = true), 0)::INTEGER, + COALESCE((SELECT COUNT(*) FROM stops s WHERE s.brand_id = p_brand_id AND s.date >= date_trunc('month', now())), 0)::INTEGER + FROM brands b WHERE b.id = p_brand_id; +END; +$$; -DROP TRIGGER IF EXISTS customers_updated_at ON customers; -CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- get_admin_users: list admin users for a brand +CREATE OR REPLACE FUNCTION get_admin_users(p_brand_id UUID DEFAULT NULL) +RETURNS TABLE ( + id UUID, + user_id UUID, + email TEXT, + name TEXT, + role TEXT, + can_manage_orders BOOLEAN, + can_manage_products BOOLEAN, + can_manage_stops BOOLEAN, + can_manage_customers BOOLEAN, + can_manage_wholesale BOOLEAN, + can_manage_billing BOOLEAN, + can_manage_settings BOOLEAN, + can_manage_water_log BOOLEAN, + can_manage_time_tracking BOOLEAN, + can_manage_route_trace BOOLEAN, + can_manage_reports BOOLEAN, + can_manage_communications BOOLEAN, + created_at TIMESTAMPTZ +) LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + IF p_brand_id IS NULL THEN + RETURN QUERY SELECT * FROM admin_users au ORDER BY au.created_at; + ELSE + RETURN QUERY + SELECT au.* FROM admin_users au + JOIN admin_user_brands aub ON aub.admin_user_id = au.id + WHERE aub.brand_id = p_brand_id + ORDER BY au.created_at; + END IF; +END; +$$; -DROP TRIGGER IF EXISTS orders_updated_at ON orders; -CREATE TRIGGER orders_updated_at BEFORE UPDATE ON orders - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- create_order_with_items: transactional order creation +CREATE OR REPLACE FUNCTION create_order_with_items( + p_brand_id UUID, + p_customer_id UUID, + p_stop_id UUID, + p_fulfillment TEXT, + p_items JSONB, + p_customer_address TEXT DEFAULT NULL, + p_customer_city TEXT DEFAULT NULL, + p_customer_state TEXT DEFAULT NULL, + p_customer_zip TEXT DEFAULT NULL, + p_notes TEXT DEFAULT NULL +) RETURNS UUID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + v_order_id UUID; + v_item JSONB; + v_total INTEGER := 0; +BEGIN + INSERT INTO orders (brand_id, customer_id, stop_id, fulfillment, customer_address, + customer_city, customer_state, customer_zip, notes) + VALUES (p_brand_id, p_customer_id, p_stop_id, p_fulfillment, p_customer_address, + p_customer_city, p_customer_state, p_customer_zip, p_notes) + RETURNING id INTO v_order_id; -DROP TRIGGER IF EXISTS brand_settings_updated_at ON brand_settings; -CREATE TRIGGER brand_settings_updated_at BEFORE UPDATE ON brand_settings - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) + LOOP + INSERT INTO order_items (order_id, product_id, quantity, price_cents, fulfillment) + VALUES ( + v_order_id, + (v_item->>'product_id')::UUID, + (v_item->>'quantity')::INTEGER, + (v_item->>'price_cents')::INTEGER, + v_item->>'fulfillment' + ); + v_total := v_total + ((v_item->>'quantity')::INTEGER * (v_item->>'price_cents')::INTEGER); + END LOOP; -DROP TRIGGER IF EXISTS email_templates_updated_at ON email_templates; -CREATE TRIGGER email_templates_updated_at BEFORE UPDATE ON email_templates - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + UPDATE orders SET total_cents = v_total WHERE id = v_order_id; + RETURN v_order_id; +END; +$$; -DROP TRIGGER IF EXISTS campaigns_updated_at ON campaigns; -CREATE TRIGGER campaigns_updated_at BEFORE UPDATE ON campaigns - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- get_public_stops_for_brand: public stops for a brand storefront +CREATE OR REPLACE FUNCTION get_public_stops_for_brand(p_brand_slug TEXT) +RETURNS TABLE ( + id UUID, + name TEXT, + location TEXT, + address TEXT, + city TEXT, + state TEXT, + zip TEXT, + date DATE, + "time" TEXT, + cutoff_date DATE, + status TEXT, + notes TEXT +) LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + RETURN QUERY + SELECT s.id, s.name, s.location, s.address, s.city, s.state, s.zip, + s.date, s."time", s.cutoff_date, s.status, s.notes + FROM stops s + JOIN brands b ON b.id = s.brand_id + WHERE b.slug = p_brand_slug + AND s.is_public = true + AND s.status = 'active' + AND s.date >= CURRENT_DATE + ORDER BY s.date ASC, s."time" ASC; +END; +$$; + +-- create_admin_user: create a new admin user +CREATE OR REPLACE FUNCTION create_admin_user( + p_user_id UUID, + p_email TEXT, + p_name TEXT, + p_role TEXT DEFAULT 'brand_admin' +) RETURNS UUID LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + v_id UUID; +BEGIN + INSERT INTO admin_users (user_id, email, name, role) + VALUES (p_user_id, p_email, p_name, p_role) + RETURNING id INTO v_id; + RETURN v_id; +END; +$$; + +-- delete_admin_user: remove an admin user +CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID) +RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + DELETE FROM admin_users WHERE id = p_id; +END; +$$; + +-- log_audit: insert an audit log entry +CREATE OR REPLACE FUNCTION log_audit( + p_user_id UUID, + p_brand_id UUID, + p_action TEXT, + p_entity_type TEXT DEFAULT NULL, + p_entity_id UUID DEFAULT NULL, + p_details JSONB DEFAULT NULL, + p_ip_address TEXT DEFAULT NULL +) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ +BEGIN + INSERT INTO audit_logs (user_id, brand_id, action, entity_type, entity_id, details, ip_address) + VALUES (p_user_id, p_brand_id, p_action, p_entity_type, p_entity_id, p_details, p_ip_address); +END; +$$; COMMIT; diff --git a/db/schema/audit.ts b/db/schema/audit.ts index 79361cd..6efa836 100644 --- a/db/schema/audit.ts +++ b/db/schema/audit.ts @@ -9,19 +9,16 @@ import { timestamp, index, } from "drizzle-orm/pg-core"; -import { tenants } from "./tenants"; -import { users } from "./tenants"; +import { brands } from "./brands"; export const auditLog = pgTable( "audit_log", { id: uuid("id").primaryKey().defaultRandom(), - tenantId: uuid("tenant_id").references(() => tenants.id, { + brandId: uuid("brand_id").references(() => brands.id, { onDelete: "cascade", }), - userId: uuid("user_id").references(() => users.id, { - onDelete: "set null", - }), + userId: uuid("user_id"), // FK to neon_auth.user(id) — plain UUID, enforced at DB level action: text("action").notNull(), targetType: text("target_type"), targetId: uuid("target_id"), @@ -31,7 +28,7 @@ export const auditLog = pgTable( .defaultNow(), }, (t) => ({ - tenantIdx: index("audit_log_tenant_idx").on(t.tenantId, t.createdAt), + brandIdx: index("audit_log_brand_idx").on(t.brandId, t.createdAt), }), ); diff --git a/db/schema/billing.ts b/db/schema/billing.ts index 779190c..f0e7440 100644 --- a/db/schema/billing.ts +++ b/db/schema/billing.ts @@ -1,6 +1,6 @@ /** - * Billing tables: plans, add-ons, subscriptions, tenant_add_ons. - * Source of truth: `db/migrations/0001_init.sql`. + * Billing: plans, add_ons, brand_add_ons. + * Source: `db/migrations/0001_init.sql`. */ import { pgTable, @@ -11,17 +11,13 @@ import { jsonb, primaryKey, } from "drizzle-orm/pg-core"; -import { - planCodeEnum, - addOnCodeEnum, - subscriptionStatusEnum, - addOnStatusEnum, -} from "./enums"; -import { tenants } from "./tenants"; +import { brands } from "./brands"; export const plans = pgTable("plans", { id: uuid("id").primaryKey().defaultRandom(), - code: text("code", { enum: planCodeEnum }).notNull().unique(), + code: text("code", { + enum: ["starter", "farm", "enterprise"], + }).notNull().unique(), name: text("name").notNull(), monthlyPriceCents: integer("monthly_price_cents").notNull(), maxUsers: integer("max_users").notNull(), @@ -35,7 +31,12 @@ export const plans = pgTable("plans", { export const addOns = pgTable("add_ons", { id: uuid("id").primaryKey().defaultRandom(), - code: text("code", { enum: addOnCodeEnum }).notNull().unique(), + code: text("code", { + enum: [ + "wholesale_portal", "harvest_reach", "ai_tools", + "water_log", "square_sync", "sms_campaigns", + ], + }).notNull().unique(), name: text("name").notNull(), monthlyPriceCents: integer("monthly_price_cents").notNull(), description: text("description"), @@ -44,37 +45,17 @@ export const addOns = pgTable("add_ons", { .defaultNow(), }); -export const subscriptions = pgTable("subscriptions", { - tenantId: uuid("tenant_id") - .primaryKey() - .references(() => tenants.id, { onDelete: "cascade" }), - planId: uuid("plan_id") - .notNull() - .references(() => plans.id), - status: text("status", { enum: subscriptionStatusEnum }) - .notNull() - .default("trialing"), - stripeSubscriptionId: text("stripe_subscription_id"), - currentPeriodEnd: timestamp("current_period_end", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const tenantAddOns = pgTable( - "tenant_add_ons", +export const brandAddOns = pgTable( + "brand_add_ons", { - tenantId: uuid("tenant_id") + brandId: uuid("brand_id") .notNull() - .references(() => tenants.id, { onDelete: "cascade" }), + .references(() => brands.id, { onDelete: "cascade" }), addOnId: uuid("add_on_id") .notNull() .references(() => addOns.id, { onDelete: "cascade" }), stripeSubscriptionId: text("stripe_subscription_id"), - status: text("status", { enum: addOnStatusEnum }) + status: text("status", { enum: ["active", "canceled"] }) .notNull() .default("active"), createdAt: timestamp("created_at", { withTimezone: true }) @@ -82,15 +63,10 @@ export const tenantAddOns = pgTable( .defaultNow(), }, (t) => ({ - pk: primaryKey({ columns: [t.tenantId, t.addOnId] }), + pk: primaryKey({ columns: [t.brandId, t.addOnId] }), }), ); export type Plan = typeof plans.$inferSelect; -export type NewPlan = typeof plans.$inferInsert; export type AddOn = typeof addOns.$inferSelect; -export type NewAddOn = typeof addOns.$inferInsert; -export type Subscription = typeof subscriptions.$inferSelect; -export type NewSubscription = typeof subscriptions.$inferInsert; -export type TenantAddOn = typeof tenantAddOns.$inferSelect; -export type NewTenantAddOn = typeof tenantAddOns.$inferInsert; +export type BrandAddOn = typeof brandAddOns.$inferSelect; diff --git a/db/schema/brand.ts b/db/schema/brand.ts index 51d058c..147a8be 100644 --- a/db/schema/brand.ts +++ b/db/schema/brand.ts @@ -1,28 +1,76 @@ /** - * Brand settings. One row per tenant. Source of truth: - * `db/migrations/0001_init.sql`. + * Brand settings + brand features. Source: `db/migrations/0001_init.sql`. */ -import { pgTable, uuid, text, jsonb, timestamp } from "drizzle-orm/pg-core"; -import { tenants } from "./tenants"; +import { + pgTable, + uuid, + text, + jsonb, + boolean, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { brands } from "./brands"; -export const brandSettings = pgTable("brand_settings", { - tenantId: uuid("tenant_id") - .primaryKey() - .references(() => tenants.id, { onDelete: "cascade" }), - brandName: text("brand_name").notNull(), - tagline: text("tagline"), - aboutHtml: text("about_html"), - primaryColor: text("primary_color").default("#0F766E"), - logoStorageKey: text("logo_storage_key"), - heroStorageKey: text("hero_storage_key"), - contactEmail: text("contact_email"), - contactPhone: text("contact_phone"), - customFooterText: text("custom_footer_text"), - featureFlags: jsonb("feature_flags").notNull().default({}), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); +export const brandSettings = pgTable( + "brand_settings", + { + brandId: uuid("brand_id") + .primaryKey() + .references(() => brands.id, { onDelete: "cascade" }), + legalBusinessName: text("legal_business_name"), + phone: text("phone"), + email: text("email"), + websiteUrl: text("website_url"), + streetAddress: text("street_address"), + city: text("city"), + state: text("state"), + postalCode: text("postal_code"), + country: text("country").default("US"), + logoUrl: text("logo_url"), + logoUrlDark: text("logo_url_dark"), + heroImageUrl: text("hero_image_url"), + tagline: text("tagline"), + aboutHtml: text("about_html"), + primaryColor: text("primary_color").default("#0F766E"), + defaultEmailSignature: text("default_email_signature"), + invoiceFooterNotes: text("invoice_footer_notes"), + fromEmail: text("from_email"), + fromName: text("from_name"), + replyToEmail: text("reply_to_email"), + customFooterText: text("custom_footer_text"), + featureFlags: jsonb("feature_flags").notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export const brandFeatures = pgTable( + "brand_features", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + featureKey: text("feature_key").notNull(), + enabled: boolean("enabled").notNull().default(false), + enabledAt: timestamp("enabled_at", { withTimezone: true }), + disabledAt: timestamp("disabled_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandFeatureIdx: uniqueIndex("brand_features_brand_key_idx").on( + t.brandId, + t.featureKey, + ), + }), +); export type BrandSettings = typeof brandSettings.$inferSelect; -export type NewBrandSettings = typeof brandSettings.$inferInsert; +export type BrandFeature = typeof brandFeatures.$inferSelect; diff --git a/db/schema/brands.ts b/db/schema/brands.ts new file mode 100644 index 0000000..8ecded7 --- /dev/null +++ b/db/schema/brands.ts @@ -0,0 +1,129 @@ +/** + * Brands (Tenants). Source of truth: `db/migrations/0001_init.sql`. + * Multi-brand isolation: every business table has `brand_id` FK → brands.id. + */ +import { + pgTable, + uuid, + text, + integer, + boolean, + timestamp, + index, + uniqueIndex, + primaryKey, +} from "drizzle-orm/pg-core"; + +export const brands = pgTable( + "brands", + { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + planTier: text("plan_tier", { + enum: ["starter", "farm", "enterprise"], + }).notNull().default("starter"), + maxUsers: integer("max_users").notNull().default(2), + maxProducts: integer("max_products").notNull().default(25), + maxStopsMonthly: integer("max_stops_monthly").notNull().default(10), + stripeCustomerId: text("stripe_customer_id"), + stripeSubscriptionId: text("stripe_subscription_id"), + stripeSubscriptionStatus: text("stripe_subscription_status", { + enum: [ + "trialing", "active", "past_due", "canceled", + "incomplete", "incomplete_expired", + ], + }), + stripeCurrentPeriodEnd: timestamp("stripe_current_period_end", { + withTimezone: true, + }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + slugIdx: uniqueIndex("brands_slug_idx").on(t.slug), + }), +); + +// ── Admin Users ────────────────────────────────────────────────────────────── + +export const adminUsers = pgTable( + "admin_users", + { + id: uuid("id").primaryKey().defaultRandom(), + // FK to neon_auth.user(id) is enforced at DB level by the migration SQL. + // Drizzle can't reference tables in other schemas, so no .references() here. + userId: uuid("user_id"), + email: text("email").notNull().unique(), + name: text("name"), + role: text("role", { + enum: ["platform_admin", "brand_admin", "store_employee"], + }).notNull().default("brand_admin"), + canManageOrders: boolean("can_manage_orders").notNull().default(true), + canManageProducts: boolean("can_manage_products").notNull().default(true), + canManageStops: boolean("can_manage_stops").notNull().default(true), + canManageCustomers: boolean("can_manage_customers").notNull().default(true), + canManageWholesale: boolean("can_manage_wholesale").notNull().default(false), + canManageBilling: boolean("can_manage_billing").notNull().default(false), + canManageSettings: boolean("can_manage_settings").notNull().default(false), + canManageWaterLog: boolean("can_manage_water_log").notNull().default(false), + canManageTimeTracking: boolean("can_manage_time_tracking").notNull().default(false), + canManageRouteTrace: boolean("can_manage_route_trace").notNull().default(false), + canManageReports: boolean("can_manage_reports").notNull().default(true), + canManageCommunications: boolean("can_manage_communications").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + userIdIdx: uniqueIndex("admin_users_user_id_idx").on(t.userId), + emailIdx: uniqueIndex("admin_users_email_idx").on(t.email), + }), +); + +export const adminUserBrands = pgTable( + "admin_user_brands", + { + adminUserId: uuid("admin_user_id") + .notNull() + .references(() => adminUsers.id, { onDelete: "cascade" }), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + addedAt: timestamp("added_at", { withTimezone: true }) + .notNull() + .defaultNow(), + addedBy: uuid("added_by").references(() => adminUsers.id), + }, + (t) => ({ + pk: primaryKey({ columns: [t.adminUserId, t.brandId] }), + }), +); + +// ── Neon Auth stub (for Drizzle typing only — table is managed by Neon Auth) ── +// The actual table is neon_auth.user (singular). We can't FK-reference a table +// in another schema via Drizzle, so we store userId as a plain UUID and rely on +// the DB-level FK constraint (enforced by the migration SQL). +export const authUsers = pgTable("user", { + id: uuid("id").primaryKey(), + name: text("name"), + email: text("email"), + emailVerified: boolean("email_verified"), + image: text("image"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), +}); + +export type Brand = typeof brands.$inferSelect; +export type NewBrand = typeof brands.$inferInsert; +export type AdminUser = typeof adminUsers.$inferSelect; +export type NewAdminUser = typeof adminUsers.$inferInsert; +export type AdminUserBrand = typeof adminUserBrands.$inferSelect; +export type NewAdminUserBrand = typeof adminUserBrands.$inferInsert; diff --git a/db/schema/communications.ts b/db/schema/communications.ts new file mode 100644 index 0000000..5dd77b0 --- /dev/null +++ b/db/schema/communications.ts @@ -0,0 +1,315 @@ +/** + * Communications (Harvest Reach). + * Source: `db/migrations/0001_init.sql`. + */ +import { + pgTable, + uuid, + text, + integer, + boolean, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { brands, adminUsers } from "./brands"; + +export const communicationSettings = pgTable( + "communication_settings", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .unique() + .references(() => brands.id, { onDelete: "cascade" }), + defaultSenderEmail: text("default_sender_email"), + defaultSenderName: text("default_sender_name"), + replyToEmail: text("reply_to_email"), + emailProvider: text("email_provider").notNull().default("resend"), + emailFooterHtml: text("email_footer_html"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export const communicationTemplates = pgTable( + "communication_templates", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + name: text("name").notNull(), + subject: text("subject").notNull(), + bodyText: text("body_text").notNull().default(""), + bodyHtml: text("body_html"), + templateType: text("template_type").notNull(), + campaignType: text("campaign_type"), + createdBy: uuid("created_by"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("communication_templates_brand_idx").on(t.brandId), + }), +); + +export const communicationSegments = pgTable( + "communication_segments", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description"), + rules: jsonb("rules").notNull().default({}), + createdBy: uuid("created_by"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("communication_segments_brand_idx").on(t.brandId), + }), +); + +export const communicationCampaigns = pgTable( + "communication_campaigns", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + name: text("name").notNull(), + subject: text("subject"), + bodyText: text("body_text"), + bodyHtml: text("body_html"), + templateId: uuid("template_id").references( + () => communicationTemplates.id, + { onDelete: "set null" }, + ), + campaignType: text("campaign_type").notNull(), + status: text("status", { + enum: ["draft", "scheduled", "sending", "sent", "canceled"], + }).notNull().default("draft"), + audienceRules: jsonb("audience_rules").notNull().default({}), + brandName: text("brand_name"), + scheduledAt: timestamp("scheduled_at", { withTimezone: true }), + sentAt: timestamp("sent_at", { withTimezone: true }), + recipientCount: integer("recipient_count").notNull().default(0), + createdBy: uuid("created_by"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("communication_campaigns_brand_idx").on(t.brandId), + statusIdx: index("communication_campaigns_status_idx").on( + t.brandId, + t.status, + ), + }), +); + +export const communicationContacts = pgTable( + "communication_contacts", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + email: text("email"), + phone: text("phone"), + firstName: text("first_name"), + lastName: text("last_name"), + fullName: text("full_name"), + source: text("source").notNull(), + externalId: text("external_id"), + customerId: uuid("customer_id"), + emailOptIn: boolean("email_opt_in").notNull().default(true), + smsOptIn: boolean("sms_opt_in").notNull().default(false), + emailOptInAt: timestamp("email_opt_in_at", { withTimezone: true }), + smsOptInAt: timestamp("sms_opt_in_at", { withTimezone: true }), + unsubscribedAt: timestamp("unsubscribed_at", { withTimezone: true }), + tags: text("tags").array().default([]), + metadata: jsonb("metadata").default({}), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("communication_contacts_brand_idx").on(t.brandId), + emailIdx: index("communication_contacts_email_idx").on( + t.brandId, + t.email, + ), + }), +); + +export const communicationMessageLogs = pgTable( + "communication_message_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + campaignId: uuid("campaign_id").references( + () => communicationCampaigns.id, + { onDelete: "set null" }, + ), + contactId: uuid("contact_id").references( + () => communicationContacts.id, + { onDelete: "set null" }, + ), + customerEmail: text("customer_email"), + deliveryMethod: text("delivery_method").notNull(), + subject: text("subject"), + bodyPreview: text("body_preview"), + status: text("status").notNull(), + sentAt: timestamp("sent_at", { withTimezone: true }), + errorMessage: text("error_message"), + eventType: text("event_type"), + eventId: uuid("event_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("communication_message_logs_brand_idx").on(t.brandId), + campaignIdx: index( + "communication_message_logs_campaign_idx", + ).on(t.campaignId), + }), +); + +export const customerCommunicationPreferences = pgTable( + "customer_communication_preferences", + { + id: uuid("id").primaryKey().defaultRandom(), + customerId: uuid("customer_id").notNull(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + emailOptIn: boolean("email_opt_in").notNull().default(true), + smsOptIn: boolean("sms_opt_in").notNull().default(false), + emailOptInAt: timestamp("email_opt_in_at", { withTimezone: true }), + smsOptInAt: timestamp("sms_opt_in_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + customerBrandIdx: uniqueIndex( + "customer_communication_prefs_customer_brand_idx", + ).on(t.customerId, t.brandId), + }), +); + +// Email automation sequences +export const abandonedCartRecovery = pgTable( + "abandoned_cart_recovery", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + customerId: uuid("customer_id"), + contactEmail: text("contact_email").notNull(), + contactName: text("contact_name"), + cartSnapshot: jsonb("cart_snapshot").notNull(), + brandName: text("brand_name"), + locale: text("locale").default("en"), + sequenceStep: integer("sequence_step").default(0), + lastEmailSentAt: timestamp("last_email_sent_at", { + withTimezone: true, + }), + nextEmailAt: timestamp("next_email_at", { withTimezone: true }), + status: text("status", { + enum: ["active", "recovered", "expired", "manually_closed"], + }).default("active"), + recoveredOrderId: uuid("recovered_order_id"), + recoveredAt: timestamp("recovered_at", { withTimezone: true }), + expiredAt: timestamp("expired_at", { withTimezone: true }), + manuallyClosedAt: timestamp("manually_closed_at", { + withTimezone: true, + }), + manuallyClosedBy: uuid("manually_closed_by").references( + () => adminUsers.id, + ), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export const welcomeEmailSequence = pgTable( + "welcome_email_sequence", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + contactId: uuid("contact_id").references( + () => communicationContacts.id, + { onDelete: "cascade" }, + ), + contactEmail: text("contact_email").notNull(), + contactName: text("contact_name"), + brandName: text("brand_name"), + locale: text("locale").default("en"), + sequenceStep: integer("sequence_step").default(0), + lastEmailSentAt: timestamp("last_email_sent_at", { + withTimezone: true, + }), + nextEmailAt: timestamp("next_email_at", { withTimezone: true }), + status: text("status", { + enum: ["active", "completed", "unsubscribed", "bounced"], + }).default("active"), + completedAt: timestamp("completed_at", { withTimezone: true }), + unsubscribedAt: timestamp("unsubscribed_at", { withTimezone: true }), + bouncedAt: timestamp("bounced_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export type CommunicationSettings = + typeof communicationSettings.$inferSelect; +export type CommunicationTemplate = typeof communicationTemplates.$inferSelect; +export type CommunicationSegment = typeof communicationSegments.$inferSelect; +export type CommunicationCampaign = typeof communicationCampaigns.$inferSelect; +export type CommunicationContact = typeof communicationContacts.$inferSelect; +export type CommunicationMessageLog = typeof communicationMessageLogs.$inferSelect; +export type CustomerCommunicationPreference = + typeof customerCommunicationPreferences.$inferSelect; +export type AbandonedCartRecovery = typeof abandonedCartRecovery.$inferSelect; +export type WelcomeEmailSequence = typeof welcomeEmailSequence.$inferSelect; \ No newline at end of file diff --git a/db/schema/customers.ts b/db/schema/customers.ts index 0b5b925..dad2f44 100644 --- a/db/schema/customers.ts +++ b/db/schema/customers.ts @@ -1,5 +1,5 @@ /** - * Customers. Source of truth: `db/migrations/0001_init.sql`. + * Customers. Source: `db/migrations/0001_init.sql`. */ import { pgTable, @@ -7,25 +7,35 @@ import { text, boolean, timestamp, + varchar, + bigint, + jsonb, index, - uniqueIndex, } from "drizzle-orm/pg-core"; -import { sql } from "drizzle-orm"; -import { tenants } from "./tenants"; +import { brands } from "./brands"; export const customers = pgTable( "customers", { id: uuid("id").primaryKey().defaultRandom(), - tenantId: uuid("tenant_id") + brandId: uuid("brand_id") .notNull() - .references(() => tenants.id, { onDelete: "cascade" }), - name: text("name").notNull(), + .references(() => brands.id, { onDelete: "cascade" }), email: text("email"), phone: text("phone"), - smsOptIn: boolean("sms_opt_in").notNull().default(false), + firstName: text("first_name"), + lastName: text("last_name"), + fullName: text("full_name"), + source: text("source").notNull().default("system"), + externalId: text("external_id"), + customerId: uuid("customer_id"), emailOptIn: boolean("email_opt_in").notNull().default(true), - notes: text("notes"), + smsOptIn: boolean("sms_opt_in").notNull().default(false), + emailOptInAt: timestamp("email_opt_in_at", { withTimezone: true }), + smsOptInAt: timestamp("sms_opt_in_at", { withTimezone: true }), + unsubscribedAt: timestamp("unsubscribed_at", { withTimezone: true }), + tags: text("tags").array().notNull().default([]), + metadata: jsonb("metadata").notNull().default({}), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -34,12 +44,9 @@ export const customers = pgTable( .defaultNow(), }, (t) => ({ - tenantIdx: index("customers_tenant_idx").on(t.tenantId), - emailIdx: uniqueIndex("customers_tenant_email_idx") - .on(t.tenantId, t.email) - .where(sql`${t.email} IS NOT NULL`), + brandIdx: index("customers_brand_idx").on(t.brandId), }), ); export type Customer = typeof customers.$inferSelect; -export type NewCustomer = typeof customers.$inferInsert; +export type NewCustomer = typeof customers.$inferInsert; \ No newline at end of file diff --git a/db/schema/files.ts b/db/schema/files.ts index bb29684..03a772e 100644 --- a/db/schema/files.ts +++ b/db/schema/files.ts @@ -5,33 +5,30 @@ import { pgTable, uuid, text, - bigint, + integer, timestamp, index, } from "drizzle-orm/pg-core"; -import { tenants } from "./tenants"; -import { users } from "./tenants"; +import { brands } from "./brands"; export const files = pgTable( "files", { id: uuid("id").primaryKey().defaultRandom(), - tenantId: uuid("tenant_id").references(() => tenants.id, { + brandId: uuid("brand_id").references(() => brands.id, { onDelete: "cascade", }), storageKey: text("storage_key").notNull().unique(), mimeType: text("mime_type").notNull(), - sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(), + sizeBytes: integer("size_bytes").notNull(), purpose: text("purpose"), - uploadedBy: uuid("uploaded_by").references(() => users.id, { - onDelete: "set null", - }), + uploadedBy: uuid("uploaded_by"), // FK to neon_auth.user(id) — plain UUID, enforced at DB level createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ - tenantIdx: index("files_tenant_idx").on(t.tenantId), + brandIdx: index("files_brand_idx").on(t.brandId), }), ); diff --git a/db/schema/index.ts b/db/schema/index.ts index 6a90643..461b4e0 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -1,17 +1,19 @@ /** * Schema barrel. Re-exports every Drizzle table + inferred row type. - * - * Usage: - * import { products, type Product } from "@/db/schema"; + * Source of truth: `db/migrations/0001_init.sql`. */ -export * from "./enums"; -export * from "./tenants"; +export * from "./brands"; export * from "./billing"; export * from "./products"; export * from "./stops"; export * from "./customers"; export * from "./orders"; export * from "./brand"; +export * from "./wholesale"; +export * from "./water-log"; +export * from "./communications"; export * from "./marketing"; -export * from "./files"; -export * from "./audit"; +export * from "./time-tracking"; +export * from "./shipping"; +export * from "./support"; +export * from "./files"; \ No newline at end of file diff --git a/db/schema/marketing.ts b/db/schema/marketing.ts index 3b55cfa..2758010 100644 --- a/db/schema/marketing.ts +++ b/db/schema/marketing.ts @@ -11,15 +11,15 @@ import { index, } from "drizzle-orm/pg-core"; import { campaignStatusEnum } from "./enums"; -import { tenants } from "./tenants"; +import { brands } from "./brands"; export const emailTemplates = pgTable( "email_templates", { id: uuid("id").primaryKey().defaultRandom(), - tenantId: uuid("tenant_id") + brandId: uuid("brand_id") .notNull() - .references(() => tenants.id, { onDelete: "cascade" }), + .references(() => brands.id, { onDelete: "cascade" }), name: text("name").notNull(), subject: text("subject").notNull(), bodyHtml: text("body_html").notNull(), @@ -31,7 +31,7 @@ export const emailTemplates = pgTable( .defaultNow(), }, (t) => ({ - tenantIdx: index("email_templates_tenant_idx").on(t.tenantId), + brandIdx: index("email_templates_brand_idx").on(t.brandId), }), ); @@ -39,9 +39,9 @@ export const campaigns = pgTable( "campaigns", { id: uuid("id").primaryKey().defaultRandom(), - tenantId: uuid("tenant_id") + brandId: uuid("brand_id") .notNull() - .references(() => tenants.id, { onDelete: "cascade" }), + .references(() => brands.id, { onDelete: "cascade" }), templateId: uuid("template_id").references((): any => emailTemplates.id, { onDelete: "set null", }), @@ -60,8 +60,8 @@ export const campaigns = pgTable( .defaultNow(), }, (t) => ({ - tenantIdx: index("campaigns_tenant_idx").on(t.tenantId), - statusIdx: index("campaigns_status_idx").on(t.tenantId, t.status), + brandIdx: index("campaigns_brand_idx").on(t.brandId), + statusIdx: index("campaigns_status_idx").on(t.brandId, t.status), }), ); diff --git a/db/schema/orders.ts b/db/schema/orders.ts index abe69eb..dde1795 100644 --- a/db/schema/orders.ts +++ b/db/schema/orders.ts @@ -1,5 +1,5 @@ /** - * Orders + order_items. Source of truth: `db/migrations/0001_init.sql`. + * Orders + order_items. Source: `db/migrations/0001_init.sql`. */ import { pgTable, @@ -9,24 +9,36 @@ import { timestamp, index, } from "drizzle-orm/pg-core"; -import { orderStatusEnum, fulfillmentEnum, itemFulfillmentEnum } from "./enums"; -import { tenants } from "./tenants"; +import { brands } from "./brands"; import { customers } from "./customers"; +import { stops } from "./stops"; import { products } from "./products"; export const orders = pgTable( "orders", { id: uuid("id").primaryKey().defaultRandom(), - tenantId: uuid("tenant_id") + brandId: uuid("brand_id") .notNull() - .references(() => tenants.id, { onDelete: "cascade" }), + .references(() => brands.id, { onDelete: "cascade" }), customerId: uuid("customer_id").references(() => customers.id, { onDelete: "set null", }), + stopId: uuid("stop_id").references(() => stops.id, { + onDelete: "set null", + }), totalCents: integer("total_cents").notNull().default(0), - status: text("status", { enum: orderStatusEnum }).notNull().default("pending"), - fulfillment: text("fulfillment", { enum: fulfillmentEnum }).notNull(), + status: text("status", { + enum: ["pending", "confirmed", "fulfilled", "canceled"], + }).notNull().default("pending"), + fulfillment: text("fulfillment", { + enum: ["pickup", "ship", "mixed"], + }).notNull(), + customerAddress: text("customer_address"), + customerCity: text("customer_city"), + customerState: text("customer_state"), + customerZip: text("customer_zip"), + idempotencyKey: text("idempotency_key"), notes: text("notes"), placedAt: timestamp("placed_at", { withTimezone: true }) .notNull() @@ -36,9 +48,10 @@ export const orders = pgTable( .defaultNow(), }, (t) => ({ - tenantIdx: index("orders_tenant_idx").on(t.tenantId), + brandIdx: index("orders_brand_idx").on(t.brandId), + statusIdx: index("orders_status_idx").on(t.brandId, t.status), + stopIdx: index("orders_stop_idx").on(t.stopId), customerIdx: index("orders_customer_idx").on(t.customerId), - statusIdx: index("orders_status_idx").on(t.tenantId, t.status), }), ); @@ -54,7 +67,12 @@ export const orderItems = pgTable( }), quantity: integer("quantity").notNull(), priceCents: integer("price_cents").notNull(), - fulfillment: text("fulfillment", { enum: itemFulfillmentEnum }).notNull(), + fulfillment: text("fulfillment", { + enum: ["pickup", "ship"], + }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), }, (t) => ({ orderIdx: index("order_items_order_idx").on(t.orderId), diff --git a/db/schema/products.ts b/db/schema/products.ts index bc9b900..0cbaf90 100644 --- a/db/schema/products.ts +++ b/db/schema/products.ts @@ -1,5 +1,5 @@ /** - * Products + product_images. Source of truth: `db/migrations/0001_init.sql`. + * Products. Source: `db/migrations/0001_init.sql`. */ import { pgTable, @@ -10,21 +10,30 @@ import { timestamp, index, } from "drizzle-orm/pg-core"; -import { tenants } from "./tenants"; +import { brands } from "./brands"; export const products = pgTable( "products", { id: uuid("id").primaryKey().defaultRandom(), - tenantId: uuid("tenant_id") + brandId: uuid("brand_id") .notNull() - .references(() => tenants.id, { onDelete: "cascade" }), + .references(() => brands.id, { onDelete: "cascade" }), name: text("name").notNull(), description: text("description"), + sku: text("sku"), + type: text("type", { enum: ["standard", "wholesale", "both"] }) + .notNull() + .default("standard"), priceCents: integer("price_cents").notNull(), inventory: integer("inventory").notNull().default(0), unit: text("unit"), active: boolean("active").notNull().default(true), + isTaxable: boolean("is_taxable").notNull().default(false), + pickupType: text("pickup_type", { enum: ["pickup", "ship", "all"] }) + .notNull() + .default("all"), + imageUrl: text("image_url"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -33,11 +42,16 @@ export const products = pgTable( .defaultNow(), }, (t) => ({ - tenantIdx: index("products_tenant_idx").on(t.tenantId), - activeIdx: index("products_active_idx").on(t.tenantId, t.active), + brandIdx: index("products_brand_idx").on(t.brandId), + activeIdx: index("products_active_idx").on(t.brandId, t.active), }), ); +export type Product = typeof products.$inferSelect; +export type NewProduct = typeof products.$inferInsert; + +// ── Product Images ───────────────────────────────────────────────────────────── + export const productImages = pgTable( "product_images", { @@ -53,11 +67,9 @@ export const productImages = pgTable( .defaultNow(), }, (t) => ({ - productIdx: index("product_images_product_idx").on(t.productId, t.position), + productIdx: index("product_images_product_idx").on(t.productId), }), ); -export type Product = typeof products.$inferSelect; -export type NewProduct = typeof products.$inferInsert; export type ProductImage = typeof productImages.$inferSelect; export type NewProductImage = typeof productImages.$inferInsert; diff --git a/db/schema/shipping.ts b/db/schema/shipping.ts new file mode 100644 index 0000000..ef78c29 --- /dev/null +++ b/db/schema/shipping.ts @@ -0,0 +1,106 @@ +/** + * Shipping + Payments. Source: `db/migrations/0001_init.sql`. + */ +import { + pgTable, + uuid, + text, + numeric, + boolean, + date, + timestamp, + index, +} from "drizzle-orm/pg-core"; +import { brands } from "./brands"; +import { orders } from "./orders"; + +export const shippingSettings = pgTable( + "shipping_settings", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .unique() + .references(() => brands.id, { onDelete: "cascade" }), + carrier: text("carrier").notNull().default("fedex"), + fedexAccountNumber: text("fedex_account_number"), + fedexApiKey: text("fedex_api_key"), + fedexApiSecret: text("fedex_api_secret"), + fedexUseProduction: boolean("fedex_use_production") + .notNull() + .default(false), + defaultServiceType: text("default_service_type") + .notNull() + .default("FEDEX_GROUND"), + refrigeratedHandlingNotes: text("refrigerated_handling_notes"), + fragileHandlingNotes: text("fragile_handling_notes"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export const shipments = pgTable( + "shipments", + { + id: uuid("id").primaryKey().defaultRandom(), + orderId: uuid("order_id") + .notNull() + .references(() => orders.id, { onDelete: "cascade" }), + carrier: text("carrier").notNull().default("fedex"), + serviceType: text("service_type").notNull(), + trackingNumber: text("tracking_number"), + labelUrl: text("label_url"), + rateCharged: numeric("rate_charged", { precision: 10, scale: 2 }), + estimatedDeliveryDate: date("estimated_delivery_date"), + isRefrigerated: boolean("is_refrigerated").notNull().default(false), + isFragile: boolean("is_fragile").notNull().default(false), + handlingNotes: text("handling_notes"), + status: text("status", { + enum: [ + "created", "label_printed", "picked_up", + "in_transit", "delivered", "exception", + ], + }).notNull().default("created"), + fedexShipmentId: text("fedex_shipment_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + createdBy: uuid("created_by"), + }, + (t) => ({ + orderIdx: index("shipments_order_idx").on(t.orderId), + }), +); + +export const paymentSettings = pgTable( + "payment_settings", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .unique() + .references(() => brands.id, { onDelete: "cascade" }), + provider: text("provider"), + stripePublishableKey: text("stripe_publishable_key"), + stripeSecretKey: text("stripe_secret_key"), + squareAccessToken: text("square_access_token"), + squareLocationId: text("square_location_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export type ShippingSettings = typeof shippingSettings.$inferSelect; +export type Shipment = typeof shipments.$inferSelect; +export type PaymentSettings = typeof paymentSettings.$inferSelect; \ No newline at end of file diff --git a/db/schema/stops.ts b/db/schema/stops.ts index 36e09ff..3a7ff52 100644 --- a/db/schema/stops.ts +++ b/db/schema/stops.ts @@ -1,28 +1,39 @@ /** - * Stops. Source of truth: `db/migrations/0001_init.sql`. + * Stops + Locations. Source: `db/migrations/0001_init.sql`. */ import { pgTable, uuid, text, - jsonb, + date, + boolean, timestamp, index, } from "drizzle-orm/pg-core"; -import { stopStatusEnum } from "./enums"; -import { tenants } from "./tenants"; +import { brands } from "./brands"; export const stops = pgTable( "stops", { id: uuid("id").primaryKey().defaultRandom(), - tenantId: uuid("tenant_id") + brandId: uuid("brand_id") .notNull() - .references(() => tenants.id, { onDelete: "cascade" }), + .references(() => brands.id, { onDelete: "cascade" }), name: text("name").notNull(), - address: text("address").notNull(), - schedule: jsonb("schedule").notNull().default([]), - status: text("status", { enum: stopStatusEnum }).notNull().default("active"), + location: text("location").notNull(), + address: text("address"), + city: text("city"), + state: text("state"), + zip: text("zip"), + date: date("date").notNull(), + // "time" is a reserved word — quoted in SQL, unquoted in JS + + time: text("time"), + cutoffDate: date("cutoff_date"), + status: text("status", { enum: ["active", "paused", "closed"] }) + .notNull() + .default("active"), + isPublic: boolean("is_public").notNull().default(true), notes: text("notes"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() @@ -32,10 +43,43 @@ export const stops = pgTable( .defaultNow(), }, (t) => ({ - tenantIdx: index("stops_tenant_idx").on(t.tenantId), - statusIdx: index("stops_status_idx").on(t.tenantId, t.status), + brandIdx: index("stops_brand_idx").on(t.brandId), + statusIdx: index("stops_status_idx").on(t.brandId, t.status), + dateIdx: index("stops_date_idx").on(t.brandId, t.date), + }), +); + +export const locations = pgTable( + "locations", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + name: text("name").notNull(), + address: text("address"), + city: text("city"), + state: text("state"), + zip: text("zip"), + phone: text("phone"), + contactName: text("contact_name"), + contactEmail: text("contact_email"), + notes: text("notes"), + active: boolean("active").notNull().default(true), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("locations_brand_idx").on(t.brandId), }), ); export type Stop = typeof stops.$inferSelect; export type NewStop = typeof stops.$inferInsert; +export type Location = typeof locations.$inferSelect; +export type NewLocation = typeof locations.$inferInsert; diff --git a/db/schema/support.ts b/db/schema/support.ts new file mode 100644 index 0000000..5befeae --- /dev/null +++ b/db/schema/support.ts @@ -0,0 +1,297 @@ +/** + * Support tables: referrals, changelogs, onboarding, api_keys, + * notifications, operational events, audit logs. + * Source: `db/migrations/0001_init.sql`. + */ +import { + pgTable, + uuid, + text, + varchar, + boolean, + timestamp, + jsonb, + integer, + numeric, + time, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { brands, adminUsers } from "./brands"; + +// ── Referrals ────────────────────────────────────────────────────────────── + +export const referralCodes = pgTable( + "referral_codes", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id").references(() => brands.id, { + onDelete: "cascade", + }), + referrerUserId: uuid("referrer_user_id").notNull(), + referralCode: varchar("referral_code", { length: 50 }).notNull().unique(), + referrerEmail: varchar("referrer_email", { length: 255 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }), + maxUses: integer("max_uses").default(1), + currentUses: integer("current_uses").default(0), + isActive: boolean("is_active").default(true), + rewardType: varchar("reward_type", { length: 50 }).default("percentage"), + rewardValue: numeric("reward_value", { precision: 10, scale: 2 }).default("20.00"), + metadata: jsonb("metadata").default({}), + }, +); + +export const referralRedemptions = pgTable( + "referral_redemptions", + { + id: uuid("id").primaryKey().defaultRandom(), + referralCodeId: uuid("referral_code_id").references( + () => referralCodes.id, + { onDelete: "cascade" }, + ), + brandId: uuid("brand_id").references(() => brands.id, { + onDelete: "cascade", + }), + referredUserId: uuid("referred_user_id").notNull(), + referredEmail: varchar("referred_email", { length: 255 }).notNull(), + redeemedAt: timestamp("redeemed_at", { withTimezone: true }) + .notNull() + .defaultNow(), + rewardType: varchar("reward_type", { length: 50 }), + rewardValue: numeric("reward_value", { precision: 10, scale: 2 }), + isCreditApplied: boolean("is_credit_applied").default(false), + signupPlan: varchar("signup_plan", { length: 50 }), + signupValue: numeric("signup_value", { precision: 10, scale: 2 }), + metadata: jsonb("metadata").default({}), + }, +); + +// ── Changelogs ───────────────────────────────────────────────────────────── + +export const changelogs = pgTable( + "changelogs", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id").references(() => brands.id, { + onDelete: "cascade", + }), + version: varchar("version", { length: 50 }).notNull(), + title: varchar("title", { length: 255 }).notNull(), + description: text("description"), + content: jsonb("content").notNull().default([]), + releasedAt: timestamp("released_at", { withTimezone: true }) + .notNull() + .defaultNow(), + isPublished: boolean("is_published").default(false), + featureType: varchar("feature_type", { length: 50 }).default("general"), + metadata: jsonb("metadata").default({}), + }, + (t) => ({ + brandVersionIdx: uniqueIndex("changelogs_brand_version_idx").on( + t.brandId, + t.version, + ), + }), +); + +export const changelogReads = pgTable( + "changelog_reads", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").notNull(), + changelogId: uuid("changelog_id").references(() => changelogs.id, { + onDelete: "cascade", + }), + readAt: timestamp("read_at", { withTimezone: true }) + .notNull() + .defaultNow(), + dismissed: boolean("dismissed").default(false), + }, + (t) => ({ + userChangelogIdx: uniqueIndex("changelog_reads_user_changelog_idx").on( + t.userId, + t.changelogId, + ), + }), +); + +// ── Onboarding ───────────────────────────────────────────────────────────── + +export const onboardingProgress = pgTable( + "onboarding_progress", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id").references(() => brands.id, { + onDelete: "cascade", + }), + userId: uuid("user_id").notNull(), + currentStep: varchar("current_step", { length: 50 }).notNull(), + completedSteps: jsonb("completed_steps").default([]), + skippedSteps: jsonb("skipped_steps").default([]), + data: jsonb("data").default({}), + startedAt: timestamp("started_at", { withTimezone: true }) + .notNull() + .defaultNow(), + completedAt: timestamp("completed_at", { withTimezone: true }), + isCompleted: boolean("is_completed").default(false), + metadata: jsonb("metadata").default({}), + }, + (t) => ({ + brandUserIdx: uniqueIndex("onboarding_progress_brand_user_idx").on( + t.brandId, + t.userId, + ), + }), +); + +// ── API Keys ──────────────────────────────────────────────────────────────── + +export const apiKeys = pgTable( + "api_keys", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id").references(() => brands.id, { + onDelete: "cascade", + }), + name: varchar("name", { length: 255 }).notNull(), + keyHash: varchar("key_hash", { length: 255 }).notNull(), + keyPrefix: varchar("key_prefix", { length: 20 }), + permissions: jsonb("permissions").default(["read"]), + rateLimit: integer("rate_limit").default(1000), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }), + isActive: boolean("is_active").default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + createdBy: uuid("created_by").notNull(), + }, + (t) => ({ + brandIdx: index("api_keys_brand_idx").on(t.brandId), + }), +); + +// ── Notification Preferences ─────────────────────────────────────────────── + +export const notificationPreferences = pgTable( + "notification_preferences", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").notNull(), + brandId: uuid("brand_id").references(() => brands.id, { + onDelete: "cascade", + }), + emailOrders: boolean("email_orders").default(true), + emailMarketing: boolean("email_marketing").default(false), + emailReports: boolean("email_reports").default(true), + emailBilling: boolean("email_billing").default(true), + smsOrders: boolean("sms_orders").default(false), + smsMarketing: boolean("sms_marketing").default(false), + pushOrders: boolean("push_orders").default(true), + pushMarketing: boolean("push_marketing").default(false), + quietHoursStart: time("quiet_hours_start"), + quietHoursEnd: time("quiet_hours_end"), + timezone: varchar("timezone", { length: 50 }).default("America/New_York"), + metadata: jsonb("metadata").default({}), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + userBrandIdx: uniqueIndex("notification_preferences_user_brand_idx").on( + t.userId, + t.brandId, + ), + }), +); + +// ── Operational Events ───────────────────────────────────────────────────── + +export const operationalEvents = pgTable( + "operational_events", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + eventType: text("event_type").notNull(), + entityType: text("entity_type"), + entityId: uuid("entity_id"), + actorType: text("actor_type"), + actorId: uuid("actor_id"), + source: text("source").notNull().default("system"), + payload: jsonb("payload").notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("operational_events_brand_idx").on(t.brandId), + typeIdx: index("operational_events_type_idx").on(t.brandId, t.eventType), + }), +); + +// ── Audit Logs ───────────────────────────────────────────────────────────── + +export const auditLogs = pgTable( + "audit_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id"), + brandId: uuid("brand_id").references(() => brands.id, { + onDelete: "set null", + }), + action: text("action").notNull(), + entityType: text("entity_type"), + entityId: uuid("entity_id"), + details: jsonb("details"), + ipAddress: text("ip_address"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("audit_logs_brand_idx").on(t.brandId), + userIdx: index("audit_logs_user_idx").on(t.userId), + createdIdx: index("audit_logs_created_idx").on(t.createdAt), + }), +); + +export const adminActionLogs = pgTable( + "admin_action_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + adminUserId: uuid("admin_user_id") + .notNull() + .references(() => adminUsers.id), + brandId: uuid("brand_id").references(() => brands.id, { + onDelete: "set null", + }), + action: text("action").notNull(), + targetType: text("target_type"), + targetId: uuid("target_id"), + metadata: jsonb("metadata"), + ipAddress: text("ip_address"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + adminIdx: index("admin_action_logs_admin_idx").on(t.adminUserId), + createdIdx: index("admin_action_logs_created_idx").on(t.createdAt), + }), +); + +export type ReferralCode = typeof referralCodes.$inferSelect; +export type ReferralRedemption = typeof referralRedemptions.$inferSelect; +export type Changelog = typeof changelogs.$inferSelect; +export type ChangelogRead = typeof changelogReads.$inferSelect; +export type OnboardingProgress = typeof onboardingProgress.$inferSelect; +export type ApiKey = typeof apiKeys.$inferSelect; +export type NotificationPreference = typeof notificationPreferences.$inferSelect; +export type OperationalEvent = typeof operationalEvents.$inferSelect; +export type AuditLog = typeof auditLogs.$inferSelect; +export type AdminActionLog = typeof adminActionLogs.$inferSelect; \ No newline at end of file diff --git a/db/schema/tenants.ts b/db/schema/tenants.ts deleted file mode 100644 index 7f7ad88..0000000 --- a/db/schema/tenants.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Tenancy + auth tables. Source of truth: `db/migrations/0001_init.sql`. - */ -import { - pgTable, - uuid, - text, - timestamp, - index, - uniqueIndex, - primaryKey, -} from "drizzle-orm/pg-core"; -import { sql } from "drizzle-orm"; -import { - tenantStatusEnum, - authProviderEnum, - roleEnum, -} from "./enums"; - -export const tenants = pgTable("tenants", { - id: uuid("id").primaryKey().defaultRandom(), - name: text("name").notNull(), - slug: text("slug").notNull().unique(), - status: text("status", { enum: tenantStatusEnum }).notNull().default("trial"), - trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }), - stripeCustomerId: text("stripe_customer_id"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const users = pgTable( - "users", - { - id: uuid("id").primaryKey().defaultRandom(), - email: text("email").unique(), - name: text("name"), - image: text("image"), - /** - * Bcrypt / scrypt / argon2 hash, or null for OAuth-only users. - * Format is self-describing (algorithm$N$salt$hash) so we can - * migrate to a stronger KDF later without losing existing hashes. - * See `src/lib/passwords.ts` for the encoder/decoder. - */ - passwordHash: text("password_hash"), - authProvider: text("auth_provider", { enum: authProviderEnum }) - .notNull() - .default("dev"), - authSubject: text("auth_subject"), - emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (t) => ({ - authSubjectIdx: uniqueIndex("users_auth_subject_idx") - .on(t.authProvider, t.authSubject) - .where(sql`${t.authSubject} IS NOT NULL`), - }), -); - -export const tenantUsers = pgTable( - "tenant_users", - { - tenantId: uuid("tenant_id") - .notNull() - .references(() => tenants.id, { onDelete: "cascade" }), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - role: text("role", { enum: roleEnum }).notNull().default("brand_admin"), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => ({ - pk: primaryKey({ columns: [t.tenantId, t.userId] }), - userIdx: index("tenant_users_user_idx").on(t.userId), - }), -); - -export type Tenant = typeof tenants.$inferSelect; -export type NewTenant = typeof tenants.$inferInsert; -export type User = typeof users.$inferSelect; -export type NewUser = typeof users.$inferInsert; -export type TenantUser = typeof tenantUsers.$inferSelect; -export type NewTenantUser = typeof tenantUsers.$inferInsert; diff --git a/db/schema/time-tracking.ts b/db/schema/time-tracking.ts new file mode 100644 index 0000000..f2466ea --- /dev/null +++ b/db/schema/time-tracking.ts @@ -0,0 +1,161 @@ +/** + * Time Tracking. Source: `db/migrations/0001_init.sql`. + */ +import { + pgTable, + uuid, + text, + numeric, + integer, + boolean, + timestamp, + index, +} from "drizzle-orm/pg-core"; +import { brands } from "./brands"; + +export const timeTrackingSettings = pgTable( + "time_tracking_settings", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .unique() + .references(() => brands.id, { onDelete: "cascade" }), + payPeriodStartDay: integer("pay_period_start_day").notNull().default(0), + payPeriodLengthDays: integer("pay_period_length_days") + .notNull() + .default(7), + dailyOvertimeThreshold: numeric("daily_overtime_threshold", { + precision: 5, + scale: 2, + }).notNull().default("8.0"), + weeklyOvertimeThreshold: numeric("weekly_overtime_threshold", { + precision: 5, + scale: 2, + }).notNull().default("40.0"), + overtimeMultiplier: numeric("overtime_multiplier", { + precision: 3, + scale: 2, + }).notNull().default("1.50"), + overtimeNotifications: boolean("overtime_notifications") + .notNull() + .default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export const timeTrackingWorkers = pgTable( + "time_tracking_workers", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + name: text("name").notNull(), + role: text("role", { enum: ["worker", "time_admin"] }) + .notNull() + .default("worker"), + lang: text("lang").notNull().default("en"), + pin: text("pin").notNull(), + active: boolean("active").notNull().default(true), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("time_tracking_workers_brand_idx").on(t.brandId), + }), +); + +export const timeTrackingTasks = pgTable( + "time_tracking_tasks", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + name: text("name").notNull(), + nameEs: text("name_es"), + unit: text("unit", { enum: ["hours", "pieces", "units"] }) + .notNull() + .default("hours"), + sortOrder: integer("sort_order").notNull().default(0), + active: boolean("active").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("time_tracking_tasks_brand_idx").on(t.brandId), + }), +); + +export const timeTrackingLogs = pgTable( + "time_tracking_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + workerId: uuid("worker_id") + .notNull() + .references(() => timeTrackingWorkers.id, { onDelete: "cascade" }), + taskId: uuid("task_id").references(() => timeTrackingTasks.id, { + onDelete: "set null", + }), + taskName: text("task_name").notNull(), + clockIn: timestamp("clock_in", { withTimezone: true }).notNull(), + clockOut: timestamp("clock_out", { withTimezone: true }), + lunchBreakMinutes: integer("lunch_break_minutes").notNull().default(0), + notes: text("notes"), + submittedVia: text("submitted_via", { + enum: ["manual", "field", "import"], + }).notNull().default("manual"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId), + workerIdx: index("time_tracking_logs_worker_idx").on(t.workerId), + clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn), + }), +); + +export const timeTrackingNotificationLog = pgTable( + "time_tracking_notification_log", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + workerId: uuid("worker_id").references( + () => timeTrackingWorkers.id, + { onDelete: "set null" }, + ), + notificationType: text("notification_type").notNull(), + recipient: text("recipient").notNull(), + subject: text("subject"), + body: text("body").notNull(), + status: text("status", { + enum: ["pending", "sent", "failed"], + }).notNull().default("pending"), + sentAt: timestamp("sent_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect; +export type TimeTrackingWorker = typeof timeTrackingWorkers.$inferSelect; +export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect; +export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect; +export type TimeTrackingNotificationLog = + typeof timeTrackingNotificationLog.$inferSelect; \ No newline at end of file diff --git a/db/schema/water-log.ts b/db/schema/water-log.ts new file mode 100644 index 0000000..b58f3b6 --- /dev/null +++ b/db/schema/water-log.ts @@ -0,0 +1,115 @@ +/** + * Water Log. Source: `db/migrations/0001_init.sql`. + */ +import { + pgTable, + uuid, + text, + numeric, + boolean, + timestamp, + index, +} from "drizzle-orm/pg-core"; +import { brands } from "./brands"; +import { adminUsers } from "./brands"; + +export const waterHeadgates = pgTable( + "water_headgates", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + name: text("name").notNull(), + active: boolean("active").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export const waterIrrigators = pgTable( + "water_irrigators", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + name: text("name").notNull(), + pinHash: text("pin_hash").notNull(), + languagePreference: text("language_preference") + .notNull() + .default("en"), + active: boolean("active").notNull().default(true), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export const waterSessions = pgTable( + "water_sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + irrigatorId: uuid("irrigator_id") + .notNull() + .references(() => waterIrrigators.id, { onDelete: "cascade" }), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export const waterLogEntries = pgTable( + "water_log_entries", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + headgateId: uuid("headgate_id") + .notNull() + .references(() => waterHeadgates.id, { onDelete: "cascade" }), + irrigatorId: uuid("irrigator_id") + .notNull() + .references(() => waterIrrigators.id, { onDelete: "cascade" }), + measurement: numeric("measurement").notNull(), + unit: text("unit").notNull(), + notes: text("notes"), + submittedVia: text("submitted_via").notNull().default("app"), + loggedAt: timestamp("logged_at", { withTimezone: true }) + .notNull() + .defaultNow(), + loggedBy: uuid("logged_by").references(() => adminUsers.id), + }, + (t) => ({ + brandIdx: index("water_log_entries_brand_idx").on(t.brandId), + headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId), + }), +); + +export const waterAlertLog = pgTable( + "water_alert_log", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + alertType: text("alert_type").notNull(), + headgateId: uuid("headgate_id").references(() => waterHeadgates.id), + message: text("message").notNull(), + sentTo: text("sent_to"), + sentAt: timestamp("sent_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export type WaterHeadgate = typeof waterHeadgates.$inferSelect; +export type WaterIrrigator = typeof waterIrrigators.$inferSelect; +export type WaterSession = typeof waterSessions.$inferSelect; +export type WaterLogEntry = typeof waterLogEntries.$inferSelect; +export type WaterAlertLog = typeof waterAlertLog.$inferSelect; diff --git a/db/schema/wholesale.ts b/db/schema/wholesale.ts new file mode 100644 index 0000000..0e85c49 --- /dev/null +++ b/db/schema/wholesale.ts @@ -0,0 +1,373 @@ +/** + * Wholesale portal. Source: `db/migrations/0001_init.sql`. + */ +import { + pgTable, + uuid, + text, + numeric, + integer, + boolean, + timestamp, + date, + index, + uniqueIndex, + jsonb, +} from "drizzle-orm/pg-core"; +import { brands } from "./brands"; +import { authUsers } from "./brands"; + +export const wholesaleSettings = pgTable( + "wholesale_settings", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .unique() + .references(() => brands.id, { onDelete: "cascade" }), + requireApproval: boolean("require_approval").notNull().default(true), + minOrderAmount: numeric("min_order_amount", { precision: 10, scale: 2 }), + onlinePaymentEnabled: boolean("online_payment_enabled") + .notNull() + .default(false), + pickupLocation: text("pickup_location"), + fobLocation: text("fob_location"), + fromEmail: text("from_email"), + invoiceBusinessName: text("invoice_business_name"), + lastInvoiceNumber: integer("last_invoice_number").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export const wholesaleCustomers = pgTable( + "wholesale_customers", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").references(() => authUsers.id, { + onDelete: "set null", + }), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + companyName: text("company_name"), + contactName: text("contact_name"), + email: text("email"), + phone: text("phone"), + billingAddress: text("billing_address"), + shippingAddress: text("shipping_address"), + accountStatus: text("account_status", { + enum: ["active", "suspended", "inactive"], + }).notNull().default("active"), + creditLimit: numeric("credit_limit", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + depositsEnabled: boolean("deposits_enabled").notNull().default(false), + depositThreshold: numeric("deposit_threshold", { precision: 10, scale: 2 }), + depositPercentage: integer("deposit_percentage"), + orderEmail: text("order_email"), + invoiceEmail: text("invoice_email"), + adminNotes: text("admin_notes"), + role: text("role", { enum: ["buyer", "admin"] }) + .notNull() + .default("buyer"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("wholesale_customers_brand_idx").on(t.brandId), + }), +); + +export const wholesaleProducts = pgTable( + "wholesale_products", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + rcProductId: uuid("rc_product_id"), + name: text("name").notNull(), + description: text("description"), + unitType: text("unit_type").notNull().default("each"), + availability: text("availability", { + enum: ["available", "unavailable", "limited", "seasonal"], + }).notNull().default("unavailable"), + qtyAvailable: numeric("qty_available", { precision: 10, scale: 2 }) + .default("0"), + priceTiers: jsonb("price_tiers").notNull().default([]), + hpSku: text("hp_sku"), + hpItemId: text("hp_item_id"), + internalNotes: text("internal_notes"), + handlingInstructions: text("handling_instructions"), + transportTemp: text("transport_temp"), + storageWarning: text("storage_warning"), + loadingNotes: text("loading_notes"), + productLabel: text("product_label"), + packStyle: text("pack_style"), + containerType: text("container_type"), + containerSizeCode: text("container_size_code"), + unitsPerContainer: integer("units_per_container"), + containerNotes: text("container_notes"), + defaultPickupLocation: text("default_pickup_location"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("wholesale_products_brand_idx").on(t.brandId), + }), +); + +export const wholesaleOrders = pgTable( + "wholesale_orders", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + customerId: uuid("customer_id") + .notNull() + .references(() => wholesaleCustomers.id), + wcOrderId: numeric("wc_order_id"), + status: text("status", { + enum: [ + "pending", "confirmed", "in_production", "ready", + "fulfilled", "canceled", + ], + }).notNull().default("pending"), + fulfillmentStatus: text("fulfillment_status", { + enum: ["unfulfilled", "partial", "fulfilled"], + }).notNull().default("unfulfilled"), + paymentStatus: text("payment_status", { + enum: ["unpaid", "deposit_paid", "paid", "refunded"], + }).notNull().default("unpaid"), + anticipatedPickupDate: date("anticipated_pickup_date"), + subtotal: numeric("subtotal", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + depositRequired: numeric("deposit_required", { precision: 10, scale: 2 }) + .default("0"), + depositPaid: numeric("deposit_paid", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + balanceDue: numeric("balance_due", { precision: 10, scale: 2 }) + .notNull() + .default("0"), + assignedEmployeeId: uuid("assigned_employee_id"), + fulfillmentNotes: text("fulfillment_notes"), + internalNotes: text("internal_notes"), + invoiceNumber: text("invoice_number"), + invoicePdfPath: text("invoice_pdf_path"), + invoiceToken: text("invoice_token"), + invoiceType: text("invoice_type"), + depositPercentage: integer("deposit_percentage"), + fulfilledAt: timestamp("fulfilled_at", { withTimezone: true }), + fulfilledBy: uuid("fulfilled_by"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("wholesale_orders_brand_idx").on(t.brandId), + customerIdx: index("wholesale_orders_customer_idx").on(t.customerId), + statusIdx: index("wholesale_orders_status_idx").on(t.brandId, t.status), + }), +); + +export const wholesaleOrderItems = pgTable( + "wholesale_order_items", + { + id: uuid("id").primaryKey().defaultRandom(), + wholesaleOrderId: uuid("wholesale_order_id") + .notNull() + .references(() => wholesaleOrders.id, { onDelete: "cascade" }), + productId: uuid("product_id") + .notNull() + .references(() => wholesaleProducts.id), + quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(), + unitPrice: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + lineTotal: numeric("line_total", { precision: 10, scale: 2 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + orderIdx: index("wholesale_order_items_order_idx").on( + t.wholesaleOrderId, + ), + }), +); + +export const wholesaleDeposits = pgTable( + "wholesale_deposits", + { + id: uuid("id").primaryKey().defaultRandom(), + wholesaleOrderId: uuid("wholesale_order_id") + .notNull() + .references(() => wholesaleOrders.id, { onDelete: "cascade" }), + amount: numeric("amount", { precision: 10, scale: 2 }).notNull(), + paymentMethod: text("payment_method"), + reference: text("reference"), + recordedBy: uuid("recorded_by").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export const wholesaleNotifications = pgTable( + "wholesale_notifications", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + customerId: uuid("customer_id") + .notNull() + .references(() => wholesaleCustomers.id, { onDelete: "cascade" }), + notificationType: text("notification_type", { + enum: [ + "order_confirmed", "order_ready", "pickup_reminder", + "payment_reminder", "invoice", + ], + }).notNull(), + channel: text("channel", { enum: ["email", "sms"] }).notNull(), + recipient: text("recipient").notNull(), + subject: text("subject"), + body: text("body").notNull(), + status: text("status", { + enum: ["pending", "sent", "failed"], + }).notNull().default("pending"), + sentAt: timestamp("sent_at", { withTimezone: true }), + errorMessage: text("error_message"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("wholesale_notifications_brand_idx").on(t.brandId), + }), +); + +export const wholesaleCustomerProductPricing = pgTable( + "wholesale_customer_product_pricing", + { + id: uuid("id").primaryKey().defaultRandom(), + customerId: uuid("customer_id") + .notNull() + .references(() => wholesaleCustomers.id, { onDelete: "cascade" }), + productId: uuid("product_id") + .notNull() + .references(() => wholesaleProducts.id, { onDelete: "cascade" }), + customPriceCents: integer("custom_price_cents").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + customerProductIdx: uniqueIndex( + "wholesale_customer_product_pricing_cust_prod_idx", + ).on(t.customerId, t.productId), + }), +); + +export const wholesaleWebhookSettings = pgTable( + "wholesale_webhook_settings", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .unique() + .references(() => brands.id, { onDelete: "cascade" }), + url: text("url").notNull(), + secret: text("secret").notNull(), + enabled: boolean("enabled").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, +); + +export const wholesaleSyncLog = pgTable( + "wholesale_sync_log", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + eventType: text("event_type").notNull(), + orderId: uuid("order_id"), + payload: jsonb("payload"), + status: text("status", { + enum: ["pending", "sent", "failed"], + }).notNull().default("pending"), + response: text("response"), + attempts: integer("attempts").notNull().default(0), + nextRetry: timestamp("next_retry", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandIdx: index("wholesale_sync_log_brand_idx").on(t.brandId), + }), +); + +export const userCarts = pgTable( + "user_carts", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + customerId: uuid("customer_id") + .notNull() + .references(() => wholesaleCustomers.id, { onDelete: "cascade" }), + items: jsonb("items").notNull().default([]), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + customerIdx: uniqueIndex("user_carts_customer_idx").on( + t.brandId, + t.customerId, + ), + }), +); + +export type WholesaleSettings = typeof wholesaleSettings.$inferSelect; +export type WholesaleCustomer = typeof wholesaleCustomers.$inferSelect; +export type WholesaleProduct = typeof wholesaleProducts.$inferSelect; +export type WholesaleOrder = typeof wholesaleOrders.$inferSelect; +export type WholesaleOrderItem = typeof wholesaleOrderItems.$inferSelect; +export type WholesaleDeposit = typeof wholesaleDeposits.$inferSelect; +export type WholesaleNotification = typeof wholesaleNotifications.$inferSelect; +export type WholesaleWebhookSettings = + typeof wholesaleWebhookSettings.$inferSelect; +export type WholesaleSyncLog = typeof wholesaleSyncLog.$inferSelect; +export type UserCart = typeof userCarts.$inferSelect; diff --git a/db/seed.ts b/db/seed.ts index cc3fe88..168a472 100644 --- a/db/seed.ts +++ b/db/seed.ts @@ -6,36 +6,20 @@ * npm run db:seed * * Populates: - * - 3 plans (Starter / Farm / Enterprise) - * - 6 add-ons - * - 2 tenants (Tuxedo, Indian River Direct) - * - 1 platform-admin user + 1 brand_admin per tenant - * - brand_settings per tenant - * - sample products, stops, customers per tenant - * - sample email templates + a draft campaign + * - 2 brands (Tuxedo, Indian River Direct) + * - brand_settings per brand + * - sample products, stops, customers per brand + * - sample communication templates + a draft campaign + * + * NOTE: Admin users are managed by Neon Auth. To create an admin user: + * 1. Sign up / sign in via the app (creates a neon_auth.user row) + * 2. Manually insert into admin_users + admin_user_brands: + * INSERT INTO admin_users (email, name, role) VALUES ('you@example.com', 'Your Name', 'brand_admin'); + * INSERT INTO admin_user_brands (admin_user_id, brand_id, role) VALUES (, , 'brand_admin'); */ import "dotenv/config"; import { Pool } from "pg"; -import { randomBytes, scryptSync } from "node:crypto"; -// `src/lib/passwords.ts` has `import "server-only"` which throws when this -// script runs outside a Next.js server context. Inline the same scrypt -// format here (must match `verifyPassword` in passwords.ts) so the seed -// can run from a plain Node process. -const SALT_LEN = 16; -const KEY_LEN = 64; -const DEFAULT_N = 16384; -const ALGO = "scrypt"; -function hashPassword(plain: string): string { - const salt = randomBytes(SALT_LEN).toString("hex"); - const hash = scryptSync(plain, salt, KEY_LEN, { N: DEFAULT_N }).toString("hex"); - return `${ALGO}$${DEFAULT_N}$${salt}$${hash}`; -} - -// Seed needs elevated privileges (inserts into plans, add_ons, tenants — -// tables that are intentionally not RLS-scoped but the runtime app user -// can't create rows in without setting up GUCs we don't want to bother -// with here). Use DATABASE_ADMIN_URL (the superuser) for the seed. const pool = new Pool({ connectionString: process.env.DATABASE_ADMIN_URL ?? @@ -48,52 +32,8 @@ async function main() { try { await client.query("BEGIN"); - // ── Plans ──────────────────────────────────────────────────────────── - const planRows = [ - { code: "starter", name: "Starter", price: 4900, maxUsers: 1, maxProducts: 25, maxStopsMonthly: 10, features: ["products", "stops", "orders", "basic_pickup"] }, - { code: "farm", name: "Farm", price: 14900, maxUsers: 5, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support"] }, - { code: "enterprise", name: "Enterprise", price: 39900, maxUsers: 9999, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support", "ai_intelligence", "sms_campaigns", "square_sync", "water_log", "custom_development", "dedicated_sla"] }, - ]; - for (const p of planRows) { - await client.query( - `INSERT INTO plans (code, name, monthly_price_cents, max_users, max_products, max_stops_monthly, features) - VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) - ON CONFLICT (code) DO UPDATE SET - name = EXCLUDED.name, - monthly_price_cents = EXCLUDED.monthly_price_cents, - max_users = EXCLUDED.max_users, - max_products = EXCLUDED.max_products, - max_stops_monthly = EXCLUDED.max_stops_monthly, - features = EXCLUDED.features`, - [p.code, p.name, p.price, p.maxUsers, p.maxProducts, p.maxStopsMonthly, JSON.stringify(p.features)], - ); - } - console.log(`Seeded ${planRows.length} plans`); - - // ── Add-ons ────────────────────────────────────────────────────────── - const addOnRows = [ - { code: "wholesale_portal", name: "Wholesale Portal", price: 9900, description: "Self-serve wholesale storefront with order approval." }, - { code: "harvest_reach", name: "Harvest Reach", price: 7900, description: "Email + SMS campaigns and automations." }, - { code: "ai_tools", name: "AI Intelligence Pack", price: 5900, description: "Smart pricing, product descriptions, and demand forecasting." }, - { code: "water_log", name: "Water Log", price: 3900, description: "Irrigation tracking and reporting." }, - { code: "square_sync", name: "Square Sync", price: 3900, description: "Sync inventory and sales to Square POS." }, - { code: "sms_campaigns", name: "SMS Campaigns", price: 2900, description: "Send SMS blasts and automations." }, - ]; - for (const a of addOnRows) { - await client.query( - `INSERT INTO add_ons (code, name, monthly_price_cents, description) - VALUES ($1, $2, $3, $4) - ON CONFLICT (code) DO UPDATE SET - name = EXCLUDED.name, - monthly_price_cents = EXCLUDED.monthly_price_cents, - description = EXCLUDED.description`, - [a.code, a.name, a.price, a.description], - ); - } - console.log(`Seeded ${addOnRows.length} add-ons`); - - // ── Tenants + users + content ──────────────────────────────────────── - const tenantsData = [ + // ── Brands ────────────────────────────────────────────────────────────── + const brandsData = [ { slug: "tuxedo", name: "Tuxedo Citrus", @@ -104,8 +44,6 @@ async function main() { primaryColor: "#F59E0B", contactEmail: "hello@tuxedocitrus.example", contactPhone: "(555) 010-2200", - planCode: "farm", - addOns: ["wholesale_portal", "harvest_reach"], }, { slug: "indian-river-direct", @@ -117,89 +55,36 @@ async function main() { primaryColor: "#0F766E", contactEmail: "orders@indianriverdirect.example", contactPhone: "(555) 010-3300", - planCode: "starter", - addOns: ["wholesale_portal"], }, ]; - for (const t of tenantsData) { - // Upsert tenant - const tenantRes = await client.query<{ id: string }>( - `INSERT INTO tenants (name, slug, status, trial_ends_at) - VALUES ($1, $2, 'active', now() + interval '30 days') + for (const b of brandsData) { + // Upsert brand + const brandRes = await client.query<{ id: string }>( + `INSERT INTO brands (name, slug, plan_tier) + VALUES ($1, $2, 'starter') ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name RETURNING id`, - [t.name, t.slug], + [b.name, b.slug], ); - const tenantId = tenantRes.rows[0].id; + const brandId = brandRes.rows[0].id; - // Plan + subscription - const planRes = await client.query<{ id: string }>( - `SELECT id FROM plans WHERE code = $1`, - [t.planCode], - ); - const planId = planRes.rows[0].id; - await client.query( - `INSERT INTO subscriptions (tenant_id, plan_id, status, current_period_end) - VALUES ($1, $2, 'active', now() + interval '30 days') - ON CONFLICT (tenant_id) DO UPDATE SET - plan_id = EXCLUDED.plan_id, - status = EXCLUDED.status, - current_period_end = EXCLUDED.current_period_end`, - [tenantId, planId], - ); - - // Add-ons (composite PK — ON CONFLICT has a target) - for (const code of t.addOns) { - const addOnRes = await client.query<{ id: string }>( - `SELECT id FROM add_ons WHERE code = $1`, - [code], - ); - if (!addOnRes.rows[0]) continue; - const addOnId = addOnRes.rows[0].id; - await client.query( - `INSERT INTO tenant_add_ons (tenant_id, add_on_id, status) - VALUES ($1, $2, 'active') - ON CONFLICT (tenant_id, add_on_id) DO NOTHING`, - [tenantId, addOnId], - ); - } - - // Brand settings (PK is tenant_id) + // Brand settings (PK is brand_id) await client.query( `INSERT INTO brand_settings - (tenant_id, brand_name, tagline, about_html, primary_color, contact_email, contact_phone) + (brand_id, brand_name, tagline, about_html, primary_color, contact_email, contact_phone) VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (tenant_id) DO UPDATE SET + ON CONFLICT (brand_id) DO UPDATE SET brand_name = EXCLUDED.brand_name, tagline = EXCLUDED.tagline, about_html = EXCLUDED.about_html, primary_color = EXCLUDED.primary_color, contact_email = EXCLUDED.contact_email, contact_phone = EXCLUDED.contact_phone`, - [ - tenantId, t.brandName, t.tagline, t.aboutHtml, - t.primaryColor, t.contactEmail, t.contactPhone, - ], + [brandId, b.brandName, b.tagline, b.aboutHtml, b.primaryColor, b.contactEmail, b.contactPhone], ); - // Brand admin user - const userRes = await client.query<{ id: string }>( - `INSERT INTO users (email, name, auth_provider, auth_subject) - VALUES ($1, $2, 'dev', $3) - ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name - RETURNING id`, - [`admin@${t.slug}.example`, `${t.brandName} Admin`, `dev-${t.slug}-admin`], - ); - const userId = userRes.rows[0].id; - await client.query( - `INSERT INTO tenant_users (tenant_id, user_id, role) - VALUES ($1, $2, 'brand_admin') - ON CONFLICT (tenant_id, user_id) DO NOTHING`, - [tenantId, userId], - ); - - // Sample products — use WHERE NOT EXISTS (no good unique target other than id) + // Sample products const products = [ { name: "Ruby Red Grapefruit", price: 2400, unit: "40 lb case", desc: "Sweet, juicy, seedless." }, { name: "Navel Oranges", price: 2200, unit: "40 lb case", desc: "Classic eating orange, easy-peel." }, @@ -208,12 +93,12 @@ async function main() { ]; for (const p of products) { await client.query( - `INSERT INTO products (tenant_id, name, description, price_cents, inventory, unit, active) + `INSERT INTO products (brand_id, name, description, price_cents, inventory, unit, active) SELECT $1, $2, $3, $4, 100, $5, true WHERE NOT EXISTS ( - SELECT 1 FROM products WHERE tenant_id = $1 AND name = $2 + SELECT 1 FROM products WHERE brand_id = $1 AND name = $2 )`, - [tenantId, p.name, p.desc, p.price, p.unit], + [brandId, p.name, p.desc, p.price, p.unit], ); } @@ -224,16 +109,16 @@ async function main() { ]; for (const s of stops) { await client.query( - `INSERT INTO stops (tenant_id, name, address, schedule, status) + `INSERT INTO stops (brand_id, name, address, schedule, status) SELECT $1, $2, $3, $4::jsonb, 'active' WHERE NOT EXISTS ( - SELECT 1 FROM stops WHERE tenant_id = $1 AND name = $2 + SELECT 1 FROM stops WHERE brand_id = $1 AND name = $2 )`, - [tenantId, s.name, s.address, JSON.stringify(s.schedule)], + [brandId, s.name, s.address, JSON.stringify(s.schedule)], ); } - // Sample customers (email has a unique index per tenant) + // Sample customers const customers = [ { name: "Green Grocer Co.", email: "buyer@greengrocer.example", phone: "555-010-0010" }, { name: "Sunset Cafe", email: "orders@sunsetcafe.example", phone: "555-010-0020" }, @@ -241,73 +126,37 @@ async function main() { ]; for (const c of customers) { await client.query( - `INSERT INTO customers (tenant_id, name, email, phone, sms_opt_in, email_opt_in) + `INSERT INTO customers (brand_id, name, email, phone, sms_opt_in, email_opt_in) SELECT $1, $2, $3, $4, true, true WHERE NOT EXISTS ( - SELECT 1 FROM customers WHERE tenant_id = $1 AND email = $3 + SELECT 1 FROM customers WHERE brand_id = $1 AND email = $3 )`, - [tenantId, c.name, c.email, c.phone], + [brandId, c.name, c.email, c.phone], ); } - // Sample email template (id-based, use WHERE NOT EXISTS) + // Sample communication template const tmplRes = await client.query<{ id: string }>( - `INSERT INTO email_templates (tenant_id, name, subject, body_html) + `INSERT INTO communication_templates (brand_id, name, subject, body_html) SELECT $1, 'Weekly Availability', 'This week at the grove', '

Fresh this week

Hi {{name}}, our harvest is in. Reply to reserve.

' WHERE NOT EXISTS ( - SELECT 1 FROM email_templates WHERE tenant_id = $1 AND name = 'Weekly Availability' + SELECT 1 FROM communication_templates WHERE brand_id = $1 AND name = 'Weekly Availability' ) RETURNING id`, - [tenantId], + [brandId], ); if (tmplRes.rows[0]) { await client.query( - `INSERT INTO campaigns (tenant_id, template_id, name, status) + `INSERT INTO communication_campaigns (brand_id, template_id, name, status) SELECT $1, $2, 'Welcome series — week 1', 'draft' WHERE NOT EXISTS ( - SELECT 1 FROM campaigns WHERE tenant_id = $1 AND name = 'Welcome series — week 1' + SELECT 1 FROM communication_campaigns WHERE brand_id = $1 AND name = 'Welcome series — week 1' )`, - [tenantId, tmplRes.rows[0].id], + [brandId, tmplRes.rows[0].id], ); } } - console.log(`Seeded ${tenantsData.length} tenants with sample data`); - - // ── Platform admin (seeded for dev sign-in via Credentials provider) ─ - // email: admin@route-commerce.local - // password: admin (override with SEED_ADMIN_PASSWORD) - // - // The user is attached to the Tuxedo tenant with the `platform_admin` - // role so `getAdminUser()` resolves it and grants cross-tenant - // visibility. To rotate the password, re-run `npm run db:seed` - // (the UPSERT updates `password_hash`). - const tuxedoRes = await client.query<{ id: string }>( - `SELECT id FROM tenants WHERE slug = 'tuxedo'`, - ); - const tuxedoId = tuxedoRes.rows[0]?.id; - if (tuxedoId) { - const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? "admin"; - const passwordHash = hashPassword(adminPassword); - const adminRes = await client.query<{ id: string }>( - `INSERT INTO users (email, name, auth_provider, auth_subject, password_hash) - VALUES ($1, 'Platform Admin', 'dev', 'dev-platform-admin', $2) - ON CONFLICT (email) DO UPDATE SET - name = EXCLUDED.name, - password_hash = EXCLUDED.password_hash - RETURNING id`, - ["admin@route-commerce.local", passwordHash], - ); - const adminId = adminRes.rows[0].id; - await client.query( - `INSERT INTO tenant_users (tenant_id, user_id, role) - VALUES ($1, $2, 'platform_admin') - ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`, - [tuxedoId, adminId], - ); - console.log( - `Seeded platform admin: admin@route-commerce.local / ${adminPassword === "admin" ? "admin" : "(SEED_ADMIN_PASSWORD)"}`, - ); - } + console.log(`Seeded ${brandsData.length} brands with sample data`); await client.query("COMMIT"); console.log("✅ Seed complete"); diff --git a/deploy/Dockerfile.nextjs b/deploy/Dockerfile.nextjs index 8624950..8758ec7 100644 --- a/deploy/Dockerfile.nextjs +++ b/deploy/Dockerfile.nextjs @@ -1,57 +1,79 @@ # ============================================================================= # Dockerfile.nextjs — multi-stage build for the Next.js frontend # ============================================================================= -# Used by docker-compose.yml's `nextjs` service. # -# Why this looks the way it does: -# - `NEXT_PUBLIC_API_URL` must be present at BUILD time (Next.js inlines -# it into the client JS). We pass it through as an ARGs so the build -# context is reproducible (`docker build --build-arg` or via deploy.sh's -# `docker compose --env-file` flow). -# - We copy the host's pre-built `.next/` (produced by `npm run build` in -# deploy.sh) rather than running `next build` inside the image. This -# keeps the image lean and avoids double-building. +# Production-ready Docker image for Next.js with: +# - Multi-stage build for minimal image size +# - Standalone server output for containerized deployment +# - Non-root user for security +# - Health check support +# +# Build args (set via --build-arg or docker-compose args): +# - NEXT_PUBLIC_API_URL: Public URL for the frontend (inlined at build time) +# - NODE_ENV: Environment (defaults to production) +# +# Required: next.config.ts must have `output: "standalone"` enabled. # ============================================================================= -# ---- builder: produce node_modules with dev deps for the build step -------- +# ── Stage 1: Dependencies ──────────────────────────────────────────────────── FROM node:20-alpine AS deps WORKDIR /app -COPY package.json package-lock.json* ./ -RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi -# ---- builder: produce the standalone .next/ output ------------------------ +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN if [ -f package-lock.json ]; then \ + npm ci --no-audit --no-fund; \ + else \ + npm install --no-audit --no-fund; \ + fi + +# ── Stage 2: Build ─────────────────────────────────────────────────────────── FROM node:20-alpine AS builder WORKDIR /app + +# Copy node_modules from deps stage COPY --from=deps /app/node_modules ./node_modules + +# Copy source files COPY . . -# These ARGs are wired through docker-compose's `args:` block (or the CLI). -# deploy.sh exports them in the build environment. -ARG NEXT_PUBLIC_API_URL +# Set build arguments (inlined into client bundle) +ARG NEXT_PUBLIC_API_URL=http://localhost:3000 ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} -ARG NEXTJS_HOST_PORT -ENV NEXTJS_HOST_PORT=${NEXTJS_HOST_PORT} +ARG NODE_ENV=production +ENV NODE_ENV=${NODE_ENV} +# Build the Next.js application RUN npm run build -# ---- runner: minimal image, standalone server ----------------------------- +# ── Stage 3: Runtime ───────────────────────────────────────────────────────── FROM node:20-alpine AS runner WORKDIR /app + +# Set production environment ENV NODE_ENV=production ENV PORT=3000 -# Run as non-root. +# Create non-root user for security RUN addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 nextjs + && adduser --system --uid 1001 nextjs -# Copy only what the standalone server needs. +# Copy standalone server and static assets from builder COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/public ./public -USER nextjs +# Expose the port EXPOSE 3000 -# Adjust this CMD to match the actual server file your build emits. -# For `output: "standalone"` in next.config.js the file is server.js. +# Switch to non-root user +USER nextjs + +# Health check +HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=6 \ + CMD wget -qO- http://localhost:3000/ || exit 1 + +# Start the standalone server CMD ["node", "server.js"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 27930f6..7096e82 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -2,19 +2,50 @@ # docker-compose.yml — production stack consumed by deploy.sh # ============================================================================= # -# Only `postgrest` lives in docker. Postgres itself runs on the host -# (the deploy workflow applies migrations via `psql -h 127.0.0.1`). -# Next.js runs under PM2 on the host — it is NOT a docker service. +# Complete Docker stack for production deployment: +# - Next.js frontend (Node.js standalone server) +# - PostgREST API (optional, for direct PostgreSQL access) # -# The host-side port (POSTGREST_HOST_PORT) is written by the deploy -# workflow into $APP_DIR/.env. We interpolate from there with -# ${VAR:-3011} so a manual `docker compose up` without the deploy -# script still works. +# Postgres itself runs on the host (the deploy workflow applies migrations +# via `psql -h 127.0.0.1`). Next.js can also run under PM2 on the host — +# this compose file provides a Docker alternative for the frontend. +# +# The host-side ports are written by the deploy workflow into $APP_DIR/.env. +# We interpolate from there with ${VAR:-default} so a manual +# `docker compose up` without the deploy script still works. # ============================================================================= name: prod-app # default project name; deploy.sh overrides with -p services: + # ── Next.js Frontend ────────────────────────────────────────────────────────── + nextjs: + build: + context: .. + dockerfile: deploy/Dockerfile.nextjs + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000} + NODE_ENV: production + container_name: prod-app-nextjs + restart: unless-stopped + ports: + - "${NEXTJS_HOST_PORT:-3000}:3000" + environment: + NODE_ENV: production + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000} + DATABASE_URL: ${DATABASE_URL} + # Pass other env vars as needed + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s + # Volume mount for hot-reload in development (comment out for production) + # volumes: + # - ../src:/app/src:ro + + # ── PostgREST (optional direct PostgreSQL access) ──────────────────────────── postgrest: image: postgrest/postgrest:latest container_name: prod-app-postgrest @@ -29,6 +60,9 @@ services: PGRST_SERVER_PORT: 3000 # Optional: tighten CORS for your real domain PGRST_DB_TXN_END: "commit-allow-overwrite" + depends_on: + nextjs: + condition: service_healthy # Healthcheck lets `docker compose ps` show healthy state. healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"] diff --git a/eslint.config.mjs b/eslint.config.mjs index ded9c79..38cdc8b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,10 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + // Ignore legacy .js scripts that use CommonJS + "scripts/**", + "supabase/**", + "fix-agents.js", ]), // Allow setState in useEffect for PWA prompts and client-side state initialization { @@ -20,6 +24,14 @@ const eslintConfig = defineConfig([ "react-hooks/set-state-in-effect": "off", }, }, + // Relax some rules for legacy code + { + files: ["db/**", "scripts/**", "supabase/**"], + rules: { + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/no-explicit-any": "warn", + }, + }, ]); export default eslintConfig; diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..598af71 Binary files /dev/null and b/logo.png differ diff --git a/next.config.ts b/next.config.ts index c23ed8f..d500407 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // Enable standalone output for Docker/PM2 deployment + output: "standalone", + // Lock the file-tracing root to the project directory. Without this, // Next.js 16 walks up from package.json looking for a lockfile, finds // the homelab runner's stale `act` cache at diff --git a/olathe-sweet-logo-dark.png b/olathe-sweet-logo-dark.png new file mode 100644 index 0000000..48d0bb8 Binary files /dev/null and b/olathe-sweet-logo-dark.png differ diff --git a/package.json b/package.json index 6bbc7e6..1eef5d1 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,11 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.96.0", + "@aws-sdk/client-s3": "^3.1064.0", + "@aws-sdk/s3-request-presigner": "^3.1064.0", "@google/generative-ai": "^0.24.1", "@gsap/react": "^2.1.2", + "@neondatabase/auth": "^0.4.2-beta", "@sentry/nextjs": "^10.55.0", "@stripe/react-stripe-js": "^6.6.0", "@stripe/stripe-js": "^9.7.0", diff --git a/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png b/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png new file mode 100644 index 0000000..598af71 Binary files /dev/null and b/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png differ diff --git a/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png b/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png new file mode 100644 index 0000000..48d0bb8 Binary files /dev/null and b/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png differ diff --git a/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png b/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png new file mode 100644 index 0000000..48d0bb8 Binary files /dev/null and b/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png differ diff --git a/scripts/create-admin-user.ts b/scripts/create-admin-user.ts new file mode 100644 index 0000000..676f88a --- /dev/null +++ b/scripts/create-admin-user.ts @@ -0,0 +1,61 @@ +/** + * Create an admin user in Neon Auth via direct HTTP call. + * Run: npx tsx scripts/create-admin-user.ts [email] [password] [name] + * + * Makes a direct HTTP request to the Neon Auth API so we don't need + * a Next.js request context, then updates email_verified in the DB. + */ +import { config } from "dotenv"; +config({ path: ".env.local" }); +import pg from "pg"; + +const { Pool } = pg; + +async function main() { + const email = process.argv[2] ?? "admin@example.com"; + const password = process.argv[3] ?? "Admin1234!"; + const name = process.argv[4] ?? "Admin"; + + const baseUrl = process.env.NEON_AUTH_BASE_URL!; + + console.log(`Creating user: ${email}`); + + const res = await fetch(`${baseUrl}/sign-up/email`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Origin": "http://localhost:4000", + "x-neon-auth-proxy": "node", + }, + body: JSON.stringify({ email, password, name, callbackURL: "/admin" }), + }); + + const data = await res.json().catch(() => ({})); + console.log(`Sign-up status: ${res.status}`); + + if (!res.ok) { + console.error("Failed to create user:", JSON.stringify(data)); + process.exit(1); + } + + const userId = data.user?.id; + console.log("User created:", data.user?.email, "| ID:", userId); + + // Mark email verified in DB so they can log in immediately + if (userId) { + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + try { + await pool.query( + "UPDATE neon_auth.user SET email_verified = true WHERE id = $1", + [userId] + ); + console.log("Email verified in DB."); + } finally { + await pool.end(); + } + } + + console.log(`\nLog in at http://localhost:4000/login with ${email} / ${password}`); +} + +main(); diff --git a/scripts/provision-admin.ts b/scripts/provision-admin.ts new file mode 100644 index 0000000..a5a0838 --- /dev/null +++ b/scripts/provision-admin.ts @@ -0,0 +1,116 @@ +/** + * Provision an admin user by email. + * Run: npx tsx scripts/provision-admin.ts [email] [role] + * + * Role options: platform_admin, brand_admin, store_employee + */ +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(); + } + } +} + +const { Pool } = pg; + +async function main() { + const email = process.argv[2] ?? "admin@example.com"; + const role = process.argv[3] ?? "platform_admin"; + + console.log("DATABASE_URL:", process.env.DATABASE_URL ? "set" : "NOT SET"); + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + + try { + // Find the Neon Auth user by email + const userResult = await pool.query( + "SELECT id FROM neon_auth.user WHERE email = $1", + [email] + ); + + if (userResult.rows.length === 0) { + console.error(`User ${email} not found in neon_auth.user`); + console.error("Please sign up first at /login"); + process.exit(1); + } + + const userId = userResult.rows[0].id; + console.log(`Found Neon Auth user: ${userId} (${email})`); + + // Check if already in admin_users + const existingAdmin = await pool.query( + "SELECT id FROM admin_users WHERE user_id = $1", + [userId] + ); + + let adminUserId: string; + + if (existingAdmin.rows.length > 0) { + adminUserId = existingAdmin.rows[0].id; + console.log(`User already in admin_users: ${adminUserId}`); + } else { + // Insert into admin_users + const insertResult = await pool.query( + `INSERT INTO admin_users (user_id, email, name, role, + can_manage_orders, can_manage_products, can_manage_stops, + can_manage_customers, can_manage_wholesale, can_manage_billing, + can_manage_settings, can_manage_water_log, can_manage_time_tracking, + can_manage_route_trace, can_manage_reports, can_manage_communications) + VALUES ($1, $2, $3, $4, true, true, true, true, true, true, true, true, true, true, true, true) + RETURNING id`, + [userId, email, email.split("@")[0], role] + ); + adminUserId = insertResult.rows[0].id; + console.log(`Created admin_users record: ${adminUserId}`); + } + + // Get brands + const brandsResult = await pool.query("SELECT id, name, slug FROM brands LIMIT 10"); + + if (brandsResult.rows.length === 0) { + console.error("No brands found. Please create a brand first."); + process.exit(1); + } + + console.log("\nAvailable brands:"); + brandsResult.rows.forEach((b, i) => { + console.log(` ${i + 1}. ${b.name} (${b.slug}) - ${b.id}`); + }); + + // Link to first brand (or create a default one) + const brand = brandsResult.rows[0]; + + // Check if already linked + const existingLink = await pool.query( + "SELECT 1 FROM admin_user_brands WHERE admin_user_id = $1 AND brand_id = $2", + [adminUserId, brand.id] + ); + + if (existingLink.rows.length > 0) { + console.log(`Already linked to brand: ${brand.name}`); + } else { + await pool.query( + "INSERT INTO admin_user_brands (admin_user_id, brand_id) VALUES ($1, $2)", + [adminUserId, brand.id] + ); + console.log(`Linked to brand: ${brand.name} (${brand.slug})`); + } + + console.log(`\n✅ ${email} is now provisioned as ${role}!`); + console.log(` They can access the admin at http://localhost:4000/admin`); + + } finally { + await pool.end(); + } +} + +main().catch(console.error); diff --git a/scripts/seed-admin.ts b/scripts/seed-admin.ts new file mode 100644 index 0000000..44add41 --- /dev/null +++ b/scripts/seed-admin.ts @@ -0,0 +1,96 @@ +/** + * Seed an admin user for development. + * Run: npx tsx scripts/seed-admin.ts [email] [password] [name] + */ +import { config } from "dotenv"; +config({ path: ".env.local" }); +import pg from "pg"; + +const { Pool } = pg; + +async function main() { + const email = process.argv[2] ?? "admin@test.com"; + const password = process.argv[3] ?? "Admin1234!"; + const name = process.argv[4] ?? "Admin"; + + const baseUrl = process.env.NEON_AUTH_BASE_URL!; + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + + try { + // 1. Create or get brand + const brandResult = await pool.query(` + INSERT INTO brands (name, slug, plan_tier) + VALUES ('Demo Brand', 'demo', 'enterprise') + ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name + RETURNING id, name, slug + `); + const brand = brandResult.rows[0]; + console.log("✓ Brand:", brand.name, `(${brand.id})`); + + // 2. Create user in Neon Auth via direct API + const res = await fetch(`${baseUrl}/sign-up/email`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Origin": "http://localhost:4000", + "x-neon-auth-proxy": "node", + }, + body: JSON.stringify({ email, password, name, callbackURL: "/admin" }), + }); + const data = await res.json(); + + if (!res.ok && data.code !== "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") { + console.error("✗ Failed to create user:", data); + process.exit(1); + } + + const userId = data.user?.id; + console.log("✓ Neon Auth user:", email, userId ? `(${userId})` : "(already existed)"); + + // 3. Verify email in Neon Auth DB + if (userId) { + await pool.query( + `UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`, + [userId] + ); + console.log("✓ Email verified in DB"); + } + + // 4. Get user ID from neon_auth.user by email + const neonUser = await pool.query( + `SELECT id FROM neon_auth.user WHERE email = $1`, + [email] + ); + const neonUserId = neonUser.rows[0]?.id; + if (!neonUserId) { + console.error("✗ Could not find user in neon_auth.user"); + process.exit(1); + } + + // 5. Insert into admin_users (upsert) + const adminResult = await pool.query(` + INSERT INTO admin_users (user_id, email, name, role) + VALUES ($1, $2, $3, 'brand_admin') + ON CONFLICT (email) DO UPDATE SET user_id = EXCLUDED.user_id, name = EXCLUDED.name, role = EXCLUDED.role + RETURNING id + `, [neonUserId, email, name]); + const adminUser = adminResult.rows[0]; + console.log("✓ Admin user:", email, `(${adminUser.id})`); + + // 6. Link to brand + await pool.query(` + INSERT INTO admin_user_brands (admin_user_id, brand_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, [adminUser.id, brand.id]); + console.log("✓ Linked to brand:", brand.name); + + console.log(`\n🎉 All set! Log in at http://localhost:4000/login`); + console.log(` Email: ${email}`); + console.log(` Password: ${password}`); + } finally { + await pool.end(); + } +} + +main(); \ No newline at end of file diff --git a/scripts/verify-email.ts b/scripts/verify-email.ts new file mode 100644 index 0000000..70ce471 --- /dev/null +++ b/scripts/verify-email.ts @@ -0,0 +1,11 @@ +import { config } from "dotenv"; +config({ path: ".env.local" }); +import pg from "pg"; +const { Pool } = pg; +async function main() { + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + const r = await pool.query(`SELECT id, email, "emailVerified" FROM neon_auth.user WHERE email = 'admin@test.com'`); + console.log(JSON.stringify(r.rows, null, 2)); + await pool.end(); +} +main(); \ No newline at end of file diff --git a/src/actions/admin/password.ts b/src/actions/admin/password.ts index ebe9af5..47a5eb4 100644 --- a/src/actions/admin/password.ts +++ b/src/actions/admin/password.ts @@ -1,48 +1,60 @@ "use server"; -import { auth } from "@/lib/auth"; -import { query } from "@/lib/db"; +import "server-only"; +import { getSession } from "@/lib/auth"; +import { setUserPassword } from "@/lib/auth"; +import { getAdminUser } from "@/lib/admin-permissions"; + +const MIN_PASSWORD_LENGTH = 8; /** - * Update the current user's Supabase auth password. - * - * Reads the Auth.js v5 session to identify the user. The session's - * `user.id` is either: - * - a Supabase auth user id (UUID) for email/password sign-ins - * - a Google `sub` (non-UUID) for Google sign-ins — these are not - * provisioned in Supabase auth, so the RPC will reject them. Google - * users must be provisioned in Supabase auth separately. - * - * The password update itself runs as a SECURITY DEFINER PL/pgSQL function - * (`update_user_password`) inside the database, called directly via the - * shared `pg` pool. No Supabase REST hop required. + * Change a user's password. Requires admin privileges. + * + * Use this for admin-initiated password resets. For self-service + * password changes, users should use the forgot password flow. */ export async function updatePasswordAction( + userId: string, newPassword: string -): Promise<{ error?: string; userId?: string }> { - const session = await auth(); - const uid = session?.user?.id; - if (!uid) { - return { error: "Not authenticated. Please log in again." }; +): Promise<{ success: boolean; error?: string }> { + // Verify the caller is an admin + const adminUser = await getAdminUser(); + if (!adminUser) { + return { success: false, error: "Not authenticated. Please log in again." }; } - const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!UUID_REGEX.test(uid)) { - return { - error: - "Password change is not available for social sign-in accounts. Please contact an admin.", - }; + if (adminUser.role !== "platform_admin") { + return { success: false, error: "Only platform admins can change passwords." }; + } + + // Validate password + if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) { + return { success: false, error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` }; } try { - // The RPC is SECURITY DEFINER and returns a single row (or raises). - // We SELECT it (rather than SELECT update_user_password(...)) so the - // call stays a normal parameterized query and we can read the result. - await query("SELECT update_user_password($1, $2)", [uid, newPassword]); - return { userId: uid }; + const result = await setUserPassword({ + userId, + newPassword, + }); + + if (result.error) { + console.error("[updatePassword] Failed:", result.error); + return { success: false, error: result.error.message ?? "Failed to update password." }; + } + + console.log("[updatePassword] Password updated for user:", userId); + return { success: true }; } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to update password."; - return { error: message }; + console.error("[updatePassword] Unexpected error:", err); + return { success: false, error: "An unexpected error occurred." }; } } + +/** + * Get the current session user ID. Used by the change-password UI. + */ +export async function getCurrentUserId(): Promise { + const { data: session } = await getSession(); + return session?.user?.id ?? null; +} diff --git a/src/actions/admin/users.ts b/src/actions/admin/users.ts index 711b86a..c0a932d 100644 --- a/src/actions/admin/users.ts +++ b/src/actions/admin/users.ts @@ -3,9 +3,6 @@ import "server-only"; import { cookies } from "next/headers"; import { pool, query } from "@/lib/db"; -import { getMockTableData, mockBrands } from "@/lib/mock-data"; - -const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; export type AdminUserRow = { id: string; @@ -112,16 +109,30 @@ async function sendWelcomeEmailSafe(input: { name: string; role: "platform_admin" | "brand_admin" | "store_employee"; password: string; + brandId?: string; }): Promise { try { const { sendWelcomeEmail } = await import("@/lib/email-service"); + const { getBrandSettings } = await import("@/actions/brand-settings"); const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role; + + let logoUrl: string | null = null; + let brandName = "Route Commerce"; + if (input.brandId) { + const settings = await getBrandSettings(input.brandId); + if (settings.success && settings.settings) { + logoUrl = settings.settings.logo_url ?? null; + brandName = settings.settings.brand_name ?? brandName; + } + } + await sendWelcomeEmail({ to: input.to, name: input.name, role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee", - brandName: "Tuxedo Corn", + brandName, tempPassword: input.password, + logoUrl: logoUrl ?? undefined, }); } catch { // welcome email is best-effort; never block user creation @@ -131,14 +142,6 @@ async function sendWelcomeEmailSafe(input: { // ─── Public actions ───────────────────────────────────────────────────────── export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> { - if (useMockData) { - const mockUsers = getMockTableData("users") as AdminUserRow[]; - return { - users: brandId ? mockUsers.filter((u) => u.brand_id === brandId) : mockUsers, - error: null, - }; - } - try { const sql = brandId ? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number, @@ -168,35 +171,6 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse } export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { - if (useMockData) { - const mockUsers = getMockTableData("users") as AdminUserRow[]; - const newRow: AdminUserRow = { - id: `mock-${Date.now()}`, - user_id: null, - display_name: input.display_name ?? input.email.split("@")[0], - email: input.email, - phone_number: input.phone_number ?? null, - role: input.role, - brand_id: input.brand_id, - brand_name: null, - can_manage_products: input.flags.can_manage_products ?? false, - can_manage_stops: input.flags.can_manage_stops ?? false, - can_manage_orders: input.flags.can_manage_orders ?? false, - can_manage_pickup: input.flags.can_manage_pickup ?? false, - can_manage_messages: input.flags.can_manage_messages ?? false, - can_manage_refunds: input.flags.can_manage_refunds ?? false, - can_manage_users: input.flags.can_manage_users ?? false, - can_manage_water_log: input.flags.can_manage_water_log ?? false, - can_manage_reports: input.flags.can_manage_reports ?? false, - active: true, - must_change_password: input.mustChangePassword ?? true, - created_at: new Date().toISOString(), - last_login: null, - }; - mockUsers.push(newRow); - return { user: newRow, error: null }; - } - try { // No Supabase Auth — `user_id` stays NULL until the user signs in // via Auth.js and `get_admin_user_for_session` matches them by @@ -242,6 +216,7 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us name: input.display_name ?? input.email.split("@")[0], role: input.role, password: input.password, + brandId: input.brand_id ?? undefined, }); return { user: mapUserRow(rows[0]), error: null }; @@ -251,25 +226,6 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us } export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { - if (useMockData) { - const mockUsers = getMockTableData("users") as AdminUserRow[]; - const idx = mockUsers.findIndex((u) => u.id === input.id); - if (idx === -1) return { user: null, error: "User not found" }; - const merged: AdminUserRow = { ...mockUsers[idx] }; - if (input.role !== undefined) merged.role = input.role; - if (input.brand_id !== undefined) merged.brand_id = input.brand_id; - if (input.active !== undefined) merged.active = input.active; - if (input.display_name !== undefined) merged.display_name = input.display_name; - if (input.phone_number !== undefined) merged.phone_number = input.phone_number; - if (input.flags) { - for (const [k, v] of Object.entries(input.flags)) { - if (v !== undefined) (merged as Record)[k] = v; - } - } - mockUsers[idx] = merged; - return { user: merged, error: null }; - } - try { // Build a partial SET clause. Each `can_manage_*` column is set // individually — the input's `flags` partial is spread across them. @@ -306,14 +262,6 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us } export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> { - if (useMockData) { - const mockUsers = getMockTableData("users") as AdminUserRow[]; - const idx = mockUsers.findIndex((u) => u.id === id); - if (idx === -1) return { success: false, error: "User not found" }; - mockUsers.splice(idx, 1); - return { success: true, error: null }; - } - try { // No Supabase Auth — nothing to delete from the auth service. const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]); @@ -324,14 +272,6 @@ export async function deleteAdminUser(id: string): Promise<{ success: boolean; e } export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> { - if (useMockData) { - const mockUsers = getMockTableData("users") as AdminUserRow[]; - const u = mockUsers.find((m) => m.id === userId); - if (!u) return { success: false, error: "User not found" }; - u.must_change_password = true; - return { success: true, error: null }; - } - try { const { rowCount } = await query( `UPDATE admin_users SET must_change_password = true WHERE id = $1`, @@ -358,14 +298,10 @@ export async function sendPasswordResetEmail(_email: string): Promise<{ success: } export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> { - if (useMockData) { - return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null }; - } try { - // The SaaS rebuild renamed `brands` → `tenants` (see db/schema/tenants.ts). // Sort by name so the platform admin's brand picker stays stable. const { rows } = await query<{ id: string; name: string }>( - `SELECT id, name FROM tenants ORDER BY name`, + `SELECT id, name FROM brands ORDER BY name`, ); return { brands: rows, error: null }; } catch (err) { diff --git a/src/actions/ai/preferences.ts b/src/actions/ai/preferences.ts index 5d19b40..5d1af25 100644 --- a/src/actions/ai/preferences.ts +++ b/src/actions/ai/preferences.ts @@ -32,7 +32,7 @@ export async function saveAIPreferences( brandId: string, config: AIAuthConfig ): Promise<{ success: boolean; error?: string }> { - const { error } = await supabase + const result = await supabase .from("brand_ai_settings") .upsert({ brand_id: brandId, @@ -43,9 +43,9 @@ export async function saveAIPreferences( model: config.model || "gpt-4o-mini", max_tokens: config.max_tokens || 4000, updated_at: new Date().toISOString(), - }); + }) as { data: unknown; error: { message: string } | null }; - if (error) return { success: false, error: error.message }; + if (result.error) return { success: false, error: result.error.message }; return { success: true }; } diff --git a/src/actions/analytics.ts b/src/actions/analytics.ts index 4fd43fa..1d6c99f 100644 --- a/src/actions/analytics.ts +++ b/src/actions/analytics.ts @@ -64,7 +64,7 @@ export type ConversionFunnel = { // when the relevant data isn't present. // // `recent_orders` / `conversion_funnel` use the new `orders` schema directly -// (total_cents, placed_at, fulfillment, status, customer_id, tenant_id). +// (total_cents, placed_at, fulfillment, status, customer_id, brand_id). /** * Aggregate KPIs over a date window. Replaces the @@ -84,7 +84,7 @@ async function getReportsSummary( const params: unknown[] = [startDate, endDate]; let brandFilter = ""; if (brandId) { - brandFilter = `AND tenant_id = $3::uuid`; + brandFilter = `AND brand_id = $3::uuid`; params.push(brandId); } const { rows } = await pool.query<{ @@ -137,7 +137,7 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise( @@ -184,7 +184,7 @@ export async function getRevenueChart(periodDays: number = 30): Promise( @@ -219,7 +219,7 @@ export async function getTopProducts(limit: number = 5): Promise { const params: unknown[] = [startDate, endDate]; let brandFilter = ""; if (brandId) { - brandFilter = "AND tenant_id = $3::uuid"; + brandFilter = "AND brand_id = $3::uuid"; params.push(brandId); } @@ -365,7 +365,7 @@ export async function getConversionFunnel(): Promise { const params: unknown[] = []; let brandFilter = ""; if (brandId) { - brandFilter = "WHERE tenant_id = $1::uuid"; + brandFilter = "WHERE brand_id = $1::uuid"; params.push(brandId); } const { rows } = await pool.query<{ status: string }>( diff --git a/src/actions/auth-actions.ts b/src/actions/auth-actions.ts index 6c1d6dc..01adb0b 100644 --- a/src/actions/auth-actions.ts +++ b/src/actions/auth-actions.ts @@ -1,44 +1,14 @@ "use server"; import "server-only"; -import { signIn, signOut } from "@/lib/auth"; +import { signOut } from "@/lib/auth"; import { redirect } from "next/navigation"; /** - * Kick off the Google OAuth flow. Auth.js will redirect to Google's - * consent screen and then back to /api/auth/callback/google, which sets - * the session cookie and redirects to /admin. - */ -export async function signInWithGoogle(): Promise { - await signIn("google", { redirectTo: "/admin" }); -} - -/** - * Sign in with email + password. The `credentials` provider is enabled - * in dev (see `isDevLoginEnabled()` in `src/auth.config.ts`); in - * production it is omitted entirely and this action returns an - * `AuthError` (Auth.js surfaces `?error=Configuration` on /login). - * - * On a failed credential check Auth.js redirects back to - * /login?error=CredentialsSignin, which the LoginClient renders as - * "Invalid email or password." - */ -export async function signInWithCredentials(formData: FormData): Promise { - const email = String(formData.get("email") ?? "").trim().toLowerCase(); - const password = String(formData.get("password") ?? ""); - if (!email || !password) { - redirect("/login?error=MissingCredentials"); - } - await signIn("credentials", { - email, - password, - redirectTo: "/admin", - }); -} - -/** - * Sign out and clear the Auth.js session cookie. + * Sign out and clear the Neon Auth session cookie. */ export async function signOutAction(): Promise { - await signOut({ redirectTo: "/login" }); + console.log("[auth/sign-out] Signing out"); + await signOut(); + redirect("/login"); } diff --git a/src/actions/auth-signin.ts b/src/actions/auth-signin.ts deleted file mode 100644 index ea71de2..0000000 --- a/src/actions/auth-signin.ts +++ /dev/null @@ -1,32 +0,0 @@ -"use server"; - -import { signIn, signOut } from "@/lib/auth"; - -/** - * Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for - * use from client components. - * - * Why server actions? - * • The Auth.js v5 `signIn` function has to run on the server (it - * needs to set the session cookie, talk to the database adapter, - * and redirect the user to the OAuth provider). - * • Calling it from a client component via a server action keeps the - * client bundle small and avoids exposing the OAuth client secret. - * - * Usage from a client component: - *
- * - *
- * - * Note: dev/demo authentication is no longer a button on the login page. - * `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/* - * when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md. - */ - -export async function signInWithGoogle(): Promise { - await signIn("google", { redirectTo: "/admin" }); -} - -export async function signOutAction(): Promise { - await signOut({ redirectTo: "/login" }); -} diff --git a/src/actions/billing/billing-overview.ts b/src/actions/billing/billing-overview.ts index 86fbe3d..4110894 100644 --- a/src/actions/billing/billing-overview.ts +++ b/src/actions/billing/billing-overview.ts @@ -37,7 +37,7 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { pool } from "@/lib/db"; -import { withTenant } from "@/db/client"; +import { withBrand } from "@/db/client"; import { products } from "@/db/schema"; import { and, eq, count } from "drizzle-orm"; import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing"; @@ -113,14 +113,14 @@ export async function getBillingOverview( COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly, COALESCE(t.max_products, 25) AS max_products, jsonb_build_object( - 'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id), + 'users', (SELECT count(*)::int FROM admin_user_brands tu WHERE tu.brand_id = t.id), 'stops_this_month', (SELECT count(*)::int FROM stops s - WHERE s.tenant_id = t.id + WHERE s.brand_id = t.id AND s.created_at >= date_trunc('month', now())), 'products', (SELECT count(*)::int FROM products p - WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL) + WHERE p.brand_id = t.id AND p.active = true AND p.deleted_at IS NULL) ) AS usage - FROM tenants t + FROM brands t WHERE t.id = $1`, [brandId] ); @@ -136,14 +136,14 @@ export async function getBillingOverview( }>( `SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id, NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end - FROM tenants WHERE id = $1`, + FROM brands WHERE id = $1`, [brandId] ); const brand = brandRes.rows[0] ?? null; // 3) Enabled add-ons (feature flags from brand_settings) const addonsRes = await pool.query<{ feature_flags: Record | null }>( - `SELECT feature_flags FROM brand_settings WHERE tenant_id = $1`, + `SELECT feature_flags FROM brand_settings WHERE brand_id = $1`, [brandId] ); const flags = addonsRes.rows[0]?.feature_flags ?? {}; @@ -155,13 +155,13 @@ export async function getBillingOverview( // 4) Active product count — same semantics as the dashboard // (active=true). Keeps the billing page in sync with /admin // "Active Products" stat. - const productCountRows = await withTenant(brandId, (db) => + const productCountRows = await withBrand(brandId, (db) => db .select({ value: count() }) .from(products) .where( and( - eq(products.tenantId, brandId), + eq(products.brandId, brandId), eq(products.active, true) ) ) diff --git a/src/actions/billing/retail-checkout.ts b/src/actions/billing/retail-checkout.ts index 5d8c6aa..9413d06 100644 --- a/src/actions/billing/retail-checkout.ts +++ b/src/actions/billing/retail-checkout.ts @@ -9,6 +9,9 @@ type LineItem = { quantity: number; }; +// Stripe API version type - using const assertion for type safety +type StripeApiVersion = "2026-05-27.dahlia"; + export async function createRetailStripeCheckoutSession( items: LineItem[], orderId: string, @@ -20,7 +23,7 @@ export async function createRetailStripeCheckoutSession( if (!stripeKey) return { success: false, error: "Stripe not configured on this server." }; const Stripe = (await import("stripe")).default; - const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); + const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion }); const lineItems = items.map((item) => ({ price_data: { @@ -35,7 +38,7 @@ export async function createRetailStripeCheckoutSession( let brandName = "Route Commerce"; try { const brandRes = await pool.query<{ name: string }>( - "SELECT name FROM tenants WHERE id = $1 LIMIT 1", + "SELECT name FROM brands WHERE id = $1 LIMIT 1", [brandId] ); if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name; diff --git a/src/actions/billing/retail-payment-intent.ts b/src/actions/billing/retail-payment-intent.ts index 6ac0b81..ad02a05 100644 --- a/src/actions/billing/retail-payment-intent.ts +++ b/src/actions/billing/retail-payment-intent.ts @@ -2,6 +2,9 @@ import { pool } from "@/lib/db"; +// Stripe API version type +type StripeApiVersion = "2026-05-27.dahlia"; + /** * Creates a Stripe PaymentIntent for the supplied cart so the browser * can confirm the payment with embedded Stripe Elements (Apple Pay / @@ -46,7 +49,7 @@ export async function createRetailPaymentIntent( } const Stripe = (await import("stripe")).default; - const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); + const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion }); // Compute the subtotal in cents. We don't compute sales tax here — // Stripe's `automatic_tax` would be ideal but requires address collection @@ -67,7 +70,7 @@ export async function createRetailPaymentIntent( if (brandId) { try { const brandRes = await pool.query<{ name: string }>( - "SELECT name FROM tenants WHERE id = $1 LIMIT 1", + "SELECT name FROM brands WHERE id = $1 LIMIT 1", [brandId] ); if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name; diff --git a/src/actions/billing/stripe-checkout.ts b/src/actions/billing/stripe-checkout.ts index 2070761..f978167 100644 --- a/src/actions/billing/stripe-checkout.ts +++ b/src/actions/billing/stripe-checkout.ts @@ -3,6 +3,9 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { pool } from "@/lib/db"; +// Stripe API version type - using const assertion for type safety +type StripeApiVersion = "2026-05-27.dahlia"; + // ── Price ID config ──────────────────────────────────────────────────────────── // Maps plan/addon keys to Stripe price IDs via environment variables @@ -45,7 +48,7 @@ export async function createStripeCheckoutSession( // Get brand's Stripe customer ID const custRes = await pool.query<{ stripe_customer_id: string | null; name: string }>( - "SELECT stripe_customer_id, name FROM tenants WHERE id = $1 LIMIT 1", + "SELECT stripe_customer_id, name FROM brands WHERE id = $1 LIMIT 1", [brandId] ); const brand = custRes.rows[0]; @@ -54,7 +57,7 @@ export async function createStripeCheckoutSession( } const Stripe = (await import("stripe")).default; - const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); + const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion }); const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; @@ -121,7 +124,7 @@ export async function cancelAddonSubscription( // Get active subscription for this brand const subRes = await pool.query<{ stripe_subscription_id: string | null }>( - "SELECT stripe_subscription_id FROM subscriptions WHERE tenant_id = $1 LIMIT 1", + "SELECT stripe_subscription_id FROM subscriptions WHERE brand_id = $1 LIMIT 1", [brandId] ); if (subRes.rows.length === 0) { @@ -133,7 +136,7 @@ export async function cancelAddonSubscription( } const Stripe = (await import("stripe")).default; - const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); + const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion }); // Retrieve subscription and find the item for this add-on const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id); diff --git a/src/actions/billing/stripe-portal.ts b/src/actions/billing/stripe-portal.ts index b77676b..30a47b8 100644 --- a/src/actions/billing/stripe-portal.ts +++ b/src/actions/billing/stripe-portal.ts @@ -3,6 +3,29 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { pool } from "@/lib/db"; +// Stripe API version type +type StripeApiVersion = "2026-05-27.dahlia"; + +// Type for plan info response +type PlanInfo = { + plan_tier: string; + plan_name: string | null; + max_users: number; + max_stops_monthly: number; + max_products: number; + usage: { users: number; stops_this_month: number; products: number } | null; +}; + +// Type for wholesale order +type WholesaleOrder = { + id: string; + created_at: Date; + customer_name: string; + total: number; + status: string; + [key: string]: unknown; +}; + export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; @@ -15,7 +38,7 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ // Get stripe_customer_id from tenants table const custRes = await pool.query<{ stripe_customer_id: string | null }>( - "SELECT stripe_customer_id FROM tenants WHERE id = $1 LIMIT 1", + "SELECT stripe_customer_id FROM brands WHERE id = $1 LIMIT 1", [brandId] ); const stripeCustomerId = custRes.rows[0]?.stripe_customer_id; @@ -25,7 +48,7 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ } const Stripe = (await import("stripe")).default; - const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); + const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion }); const session = await stripe.billingPortal.sessions.create({ customer: stripeCustomerId, @@ -74,7 +97,7 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome } } -export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> { +export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: PlanInfo; error?: string }> { // Replicate get_brand_plan_info via a JOIN on tenants + plans const res = await pool.query<{ plan_tier: string; @@ -91,14 +114,14 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly, COALESCE(t.max_products, 25) AS max_products, jsonb_build_object( - 'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id), + 'users', (SELECT count(*)::int FROM admin_user_brands tu WHERE tu.brand_id = t.id), 'stops_this_month', (SELECT count(*)::int FROM stops s - WHERE s.tenant_id = t.id + WHERE s.brand_id = t.id AND s.created_at >= date_trunc('month', now())), 'products', (SELECT count(*)::int FROM products p - WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL) + WHERE p.brand_id = t.id AND p.active = true AND p.deleted_at IS NULL) ) AS usage - FROM tenants t + FROM brands t WHERE t.id = $1`, [brandId] ); @@ -111,7 +134,7 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool export async function getEnabledAddons(brandId: string): Promise> { // get_brand_features returns JSONB — a single object, not an array const res = await pool.query<{ feature_flags: Record | null }>( - "SELECT feature_flags FROM brand_settings WHERE tenant_id = $1 LIMIT 1", + "SELECT feature_flags FROM brand_settings WHERE brand_id = $1 LIMIT 1", [brandId] ); const flags = res.rows[0]?.feature_flags ?? {}; @@ -123,7 +146,7 @@ export async function getEnabledAddons(brandId: string): Promise { +export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise { try { const res = await pool.query( "SELECT * FROM get_wholesale_orders($1)", diff --git a/src/actions/brand-settings.ts b/src/actions/brand-settings.ts index a486b39..c58f87e 100644 --- a/src/actions/brand-settings.ts +++ b/src/actions/brand-settings.ts @@ -2,21 +2,16 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { pool } from "@/lib/db"; +import { uploadObject, BUCKETS } from "@/lib/storage"; export type UploadLogoResult = | { success: true; logoUrl: string } | { success: false; error: string }; /** - * Upload a brand logo (light or dark variant) to Supabase Storage and - * save the resulting public URL to the brand_settings row via the + * Upload a brand logo (light or dark variant) to MinIO and save the + * resulting public URL to the brand_settings row via the * `upsert_brand_settings` SECURITY DEFINER RPC. - * - * NOTE: the storage PUT itself is not a database call, so it still - * goes through the Supabase Storage REST API. Storage migration to an - * S3-compatible backend is tracked separately; until that lands, the - * public URL pattern (`/storage/v1/object/public/...`) keeps working - * for any pre-existing bucket. */ export async function uploadBrandLogo( brandId: string, @@ -42,37 +37,28 @@ export async function uploadBrandLogo( const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1]; const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`; - const storagePath = `brand-logos/${brandId}/${path}`; + const storageKey = `brand-logos/${brandId}/${path}`; - const arrayBuffer = await file.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - const uploadRes = await fetch( - `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, - { - method: "PUT", - headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" }, + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const logoUrl = await uploadObject({ + bucket: BUCKETS.BRAND_LOGOS, + key: storageKey, body: buffer, + contentType: file.type, + }); + + const saveOk = await callUpsertBrandSettings(brandId, { + [isDark ? "p_logo_url_dark" : "p_logo_url"]: logoUrl, + }); + if (!saveOk) { + return { success: false, error: "Upload succeeded but failed to save URL" }; } - ); - if (!uploadRes.ok) { - return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; + return { success: true, logoUrl }; + } catch (err) { + return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` }; } - - const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; - - const saveOk = await callUpsertBrandSettings(brandId, { - [isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl, - }); - if (!saveOk) { - return { success: false, error: "Upload succeeded but failed to save URL" }; - } - - return { success: true, logoUrl: publicUrl }; } export async function uploadOlatheSweetLogo( @@ -97,37 +83,28 @@ export async function uploadOlatheSweetLogo( } const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1]; - const storagePath = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`; + const storageKey = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`; - const arrayBuffer = await file.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - const uploadRes = await fetch( - `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, - { - method: "PUT", - headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" }, + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const logoUrl = await uploadObject({ + bucket: BUCKETS.BRAND_LOGOS, + key: storageKey, body: buffer, + contentType: file.type, + }); + + const saveOk = await callUpsertBrandSettings(brandId, { + p_olathe_sweet_logo_url: logoUrl, + }); + if (!saveOk) { + return { success: false, error: "Upload succeeded but failed to save URL" }; } - ); - if (!uploadRes.ok) { - return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; + return { success: true, logoUrl }; + } catch (err) { + return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` }; } - - const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; - - const saveOk = await callUpsertBrandSettings(brandId, { - p_olathe_sweet_logo_url: publicUrl, - }); - if (!saveOk) { - return { success: false, error: "Upload succeeded but failed to save URL" }; - } - - return { success: true, logoUrl: publicUrl }; } export async function uploadOlatheSweetLogoDark( @@ -152,37 +129,28 @@ export async function uploadOlatheSweetLogoDark( } const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1]; - const storagePath = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`; + const storageKey = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`; - const arrayBuffer = await file.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - const uploadRes = await fetch( - `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, - { - method: "PUT", - headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" }, + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const logoUrl = await uploadObject({ + bucket: BUCKETS.BRAND_LOGOS, + key: storageKey, body: buffer, + contentType: file.type, + }); + + const saveOk = await callUpsertBrandSettings(brandId, { + p_olathe_sweet_logo_url_dark: logoUrl, + }); + if (!saveOk) { + return { success: false, error: "Upload succeeded but failed to save URL" }; } - ); - if (!uploadRes.ok) { - return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; + return { success: true, logoUrl }; + } catch (err) { + return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` }; } - - const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; - - const saveOk = await callUpsertBrandSettings(brandId, { - p_olathe_sweet_logo_url_dark: publicUrl, - }); - if (!saveOk) { - return { success: false, error: "Upload succeeded but failed to save URL" }; - } - - return { success: true, logoUrl: publicUrl }; } /** diff --git a/src/actions/checkout.ts b/src/actions/checkout.ts index e63f996..c56c92a 100644 --- a/src/actions/checkout.ts +++ b/src/actions/checkout.ts @@ -110,8 +110,24 @@ export async function createOrder( // Send order receipt email try { const { sendOrderReceiptEmail } = await import("@/lib/email-service"); + const { getBrandSettingsPublic } = await import("@/actions/brand-settings"); const rawItems = data.items ?? []; const taxAmount = (data as { tax_amount?: number }).tax_amount ?? 0; + + // Look up brand settings to get the logo URL + let logoUrl: string | null = null; + if (brandId) { + // Resolve brand slug from brand ID, then fetch settings + const { rows: brandRows } = await pool.query<{ slug: string }>( + "SELECT slug FROM brands WHERE id = $1", + [brandId] + ); + if (brandRows[0]) { + const settings = await getBrandSettingsPublic(brandRows[0].slug); + logoUrl = settings.success ? (settings.settings?.logo_url ?? null) : null; + } + } + await sendOrderReceiptEmail({ customerName, customerEmail, @@ -131,6 +147,7 @@ export async function createOrder( stopTime: data.stop_time ?? undefined, stopLocation: data.stop_location ?? undefined, brandName: "Tuxedo Corn", + logoUrl, }); } catch { // Email failure should not fail the order diff --git a/src/actions/communications/campaigns.ts b/src/actions/communications/campaigns.ts index af9f3b2..03d8304 100644 --- a/src/actions/communications/campaigns.ts +++ b/src/actions/communications/campaigns.ts @@ -3,7 +3,7 @@ import { and, desc, eq, SQL } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; -import { withTenant, withPlatformAdmin } from "@/db/client"; +import { withBrand, withPlatformAdmin } from "@/db/client"; import { campaigns, emailTemplates } from "@/db/schema"; export type CampaignType = "marketing" | "operational" | "transactional"; @@ -83,7 +83,7 @@ function stripHtml(html: string | null): string { function rowToCampaign(c: CampaignRow, t?: TemplateRow | null): Campaign { return { id: c.id, - brand_id: c.tenantId, + brand_id: c.brandId, name: c.name, subject: t?.subject ?? null, body_text: stripHtml(t?.bodyHtml ?? null), @@ -111,7 +111,7 @@ export async function getCommunicationCampaigns(brandId?: string): Promise + ? await withBrand(activeBrandId, (db) => db .select({ campaign: campaigns, @@ -178,7 +178,7 @@ export async function upsertCampaign(params: { const subject = params.subject ?? ""; const bodyHtml = params.body_html ?? (params.body_text ? `

${escapeHtml(params.body_text)}

` : "

"); - const { row, template } = await withTenant(params.brand_id, async (db) => { + const { row, template } = await withBrand(params.brand_id, async (db) => { // Resolve / create the linked email_templates row. let templateId: string | null = params.template_id ?? null; let templateRow: TemplateRow | null = null; @@ -187,7 +187,7 @@ export async function upsertCampaign(params: { const existing = await db .select() .from(emailTemplates) - .where(and(eq(emailTemplates.id, templateId), eq(emailTemplates.tenantId, params.brand_id))) + .where(and(eq(emailTemplates.id, templateId), eq(emailTemplates.brandId, params.brand_id))) .limit(1); if (existing[0]) { const updated = await db @@ -207,7 +207,7 @@ export async function upsertCampaign(params: { const inserted = await db .insert(emailTemplates) .values({ - tenantId: params.brand_id, + brandId: params.brand_id, name: params.name, subject, bodyHtml, @@ -229,7 +229,7 @@ export async function upsertCampaign(params: { scheduledFor: scheduled, updatedAt: new Date(), }) - .where(and(eq(campaigns.id, params.id), eq(campaigns.tenantId, params.brand_id))) + .where(and(eq(campaigns.id, params.id), eq(campaigns.brandId, params.brand_id))) .returning(); if (!updated[0]) { return { row: null, template: null }; @@ -239,7 +239,7 @@ export async function upsertCampaign(params: { const inserted = await db .insert(campaigns) .values({ - tenantId: params.brand_id, + brandId: params.brand_id, name: params.name, templateId, status, @@ -274,8 +274,8 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom try { if (activeBrandId) { - await withTenant(activeBrandId, (db) => - db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId))), + await withBrand(activeBrandId, (db) => + db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.brandId, activeBrandId))), ); } else { await withPlatformAdmin((db) => db.delete(campaigns).where(eq(campaigns.id, campaignId))); @@ -297,12 +297,12 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro try { const result = activeBrandId - ? await withTenant(activeBrandId, (db) => + ? await withBrand(activeBrandId, (db) => db .select({ campaign: campaigns, template: emailTemplates }) .from(campaigns) .leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId)) - .where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId))) + .where(and(eq(campaigns.id, campaignId), eq(campaigns.brandId, activeBrandId))) .limit(1) .then((r) => r[0] ?? null), ) @@ -318,7 +318,7 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro if (!result) return null; - if (adminUser.role === "brand_admin" && result.campaign.tenantId !== adminUser.brand_id) { + if (adminUser.role === "brand_admin" && result.campaign.brandId !== adminUser.brand_id) { return null; } diff --git a/src/actions/communications/contacts.ts b/src/actions/communications/contacts.ts index 5002836..b31f69a 100644 --- a/src/actions/communications/contacts.ts +++ b/src/actions/communications/contacts.ts @@ -4,7 +4,7 @@ import { and, eq, ilike, or, sql, SQL } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; import { parseCSVWithLimits } from "@/lib/csv-parser"; import { buildImportPreview } from "@/lib/column-detector"; -import { withTenant, withPlatformAdmin } from "@/db/client"; +import { withBrand, withPlatformAdmin } from "@/db/client"; import { customers } from "@/db/schema"; export type ContactSource = "order" | "import" | "manual" | "admin"; @@ -19,7 +19,7 @@ export type ContactSource = "order" | "import" | "manual" | "admin"; */ export type Contact = { id: string; - tenant_id: string; + brand_id: string; email: string | null; phone: string | null; full_name: string; @@ -101,23 +101,9 @@ export type GetContactsResult = { }; function rowToContact(row: typeof customers.$inferSelect): Contact { - // Derive first/last name from the stored single `name` column. - // Multi-word names split on the first whitespace; single-word names - // populate first_name and leave last_name null. - const fullName = row.name ?? ""; - const trimmed = fullName.trim(); - let firstName: string | null = null; - let lastName: string | null = null; - if (trimmed) { - const idx = trimmed.indexOf(" "); - if (idx === -1) { - firstName = trimmed; - } else { - firstName = trimmed.slice(0, idx); - const rest = trimmed.slice(idx + 1).trim(); - lastName = rest.length > 0 ? rest : null; - } - } + const firstName = row.firstName; + const lastName = row.lastName; + const fullName = [firstName, lastName].filter(Boolean).join(" ") || ""; // Derive unsubscribed_at: the new schema only has opt-in flags, not // a timestamp. Mark the unsubscribe moment as updatedAt if opted out @@ -126,10 +112,10 @@ function rowToContact(row: typeof customers.$inferSelect): Contact { return { id: row.id, - tenant_id: row.tenantId, + brand_id: row.brandId, email: row.email, phone: row.phone, - full_name: row.name, + full_name: row.fullName ?? "", first_name: firstName, last_name: lastName, source: "manual", @@ -162,13 +148,13 @@ export async function getContacts(params: { const search = params.search?.trim() ?? ""; try { - const conds: SQL[] = [eq(customers.tenantId, params.brandId)]; + const conds: SQL[] = [eq(customers.brandId, params.brandId)]; if (search) { const like = `%${search}%`; - conds.push(or(ilike(customers.name, like), ilike(customers.email, like))!); + conds.push(or(ilike(customers.firstName, like), ilike(customers.email, like))!); } - const rows = await withTenant(params.brandId, async (db) => { + const rows = await withBrand(params.brandId, async (db) => { const [items, countRows] = await Promise.all([ db .select() @@ -233,7 +219,7 @@ export async function upsertContact(contact: { return { success: false, error: "Not authorized for this brand" }; } - const name = + const fullName = contact.full_name?.trim() || [contact.first_name, contact.last_name].filter(Boolean).join(" ").trim() || contact.email || @@ -243,18 +229,20 @@ export async function upsertContact(contact: { try { if (contact.id) { const contactId = contact.id; - const updated = await withTenant(contact.brand_id, (db) => + const updated = await withBrand(contact.brand_id, (db) => db .update(customers) .set({ - name, + fullName, email: contact.email ?? null, phone: contact.phone ?? null, + firstName: contact.first_name ?? null, + lastName: contact.last_name ?? null, smsOptIn: contact.sms_opt_in ?? false, emailOptIn: contact.email_opt_in ?? true, updatedAt: new Date(), }) - .where(and(eq(customers.id, contactId), eq(customers.tenantId, contact.brand_id))) + .where(and(eq(customers.id, contactId), eq(customers.brandId, contact.brand_id))) .returning(), ); const row = updated[0]; @@ -262,23 +250,25 @@ export async function upsertContact(contact: { return { success: true, contact: rowToContact(row) }; } - // INSERT — de-dupe on (tenant_id, email) when email is provided + // INSERT — de-dupe on (brand_id, email) when email is provided const contactEmail = contact.email; if (contactEmail) { - const existing = await withTenant(contact.brand_id, (db) => + const existing = await withBrand(contact.brand_id, (db) => db .select() .from(customers) - .where(and(eq(customers.email, contactEmail), eq(customers.tenantId, contact.brand_id))) + .where(and(eq(customers.email, contactEmail), eq(customers.brandId, contact.brand_id))) .limit(1), ); if (existing[0]) { - const updated = await withTenant(contact.brand_id, (db) => + const updated = await withBrand(contact.brand_id, (db) => db .update(customers) .set({ - name, + fullName, phone: contact.phone ?? null, + firstName: contact.first_name ?? null, + lastName: contact.last_name ?? null, smsOptIn: contact.sms_opt_in ?? existing[0].smsOptIn, emailOptIn: contact.email_opt_in ?? existing[0].emailOptIn, updatedAt: new Date(), @@ -292,14 +282,16 @@ export async function upsertContact(contact: { } } - const inserted = await withTenant(contact.brand_id, (db) => + const inserted = await withBrand(contact.brand_id, (db) => db .insert(customers) .values({ - tenantId: contact.brand_id, - name, + brandId: contact.brand_id, + fullName, email: contact.email ?? null, phone: contact.phone ?? null, + firstName: contact.first_name ?? null, + lastName: contact.last_name ?? null, smsOptIn: contact.sms_opt_in ?? false, emailOptIn: contact.email_opt_in ?? true, }) @@ -328,7 +320,7 @@ export type ImportContactsResult = { * The legacy `import_communication_contacts_batch` RPC is replaced with * an in-process batch: parse → dedupe → upsert per row, returning the * same ImportResult shape. This avoids a round-trip to the DB for each - * row and keeps the call inside the `withTenant` transaction. + * row and keeps the call inside the `withBrand` transaction. */ export async function importContactsBatch(params: { brandId: string; @@ -403,14 +395,14 @@ export async function optOutContact(params: { } try { - await withTenant(params.brandId, (db) => + await withBrand(params.brandId, (db) => db .update(customers) .set({ ...(params.method === "email" ? { emailOptIn: false } : { smsOptIn: false }), updatedAt: new Date(), }) - .where(and(eq(customers.email, params.email), eq(customers.tenantId, params.brandId))), + .where(and(eq(customers.email, params.email), eq(customers.brandId, params.brandId))), ); return { success: true }; } catch (err) { @@ -434,10 +426,10 @@ export async function deleteContact(id: string, brandId?: string): Promise<{ suc try { if (brandId) { - await withTenant(brandId, (db) => + await withBrand(brandId, (db) => db .delete(customers) - .where(and(eq(customers.id, id), eq(customers.tenantId, brandId))), + .where(and(eq(customers.id, id), eq(customers.brandId, brandId))), ); } else { // platform_admin fallback — by id only @@ -492,20 +484,20 @@ export async function exportContacts(params: { try { while (true) { - const rows = await withTenant(effectiveBrandId, (db) => + const rows = await withBrand(effectiveBrandId, (db) => db .select() .from(customers) .where( params.search ? and( - eq(customers.tenantId, effectiveBrandId), + eq(customers.brandId, effectiveBrandId), or( - ilike(customers.name, `%${params.search}%`), + ilike(customers.firstName, `%${params.search}%`), ilike(customers.email, `%${params.search}%`), ), ) - : eq(customers.tenantId, effectiveBrandId), + : eq(customers.brandId, effectiveBrandId), ) .limit(batchSize) .offset(offset) diff --git a/src/actions/communications/import-contacts.ts b/src/actions/communications/import-contacts.ts index b650184..29a2892 100644 --- a/src/actions/communications/import-contacts.ts +++ b/src/actions/communications/import-contacts.ts @@ -2,7 +2,7 @@ import { and, desc, eq } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; -import { withTenant, withPlatformAdmin } from "@/db/client"; +import { withBrand, withPlatformAdmin } from "@/db/client"; import { files } from "@/db/schema"; import { importContactsBatch, @@ -45,11 +45,11 @@ export async function uploadContactsToBucket( const storageKey = `imports/${brandId}/${timestamp}-${safeName}`; try { - const [row] = await withTenant(brandId, (db) => + const [row] = await withBrand(brandId, (db) => db .insert(files) .values({ - tenantId: brandId, + brandId: brandId, storageKey, mimeType: "text/csv", sizeBytes: file.size, @@ -130,7 +130,7 @@ export async function listImportHistory( if (!adminUser) return { success: false, error: "Not authenticated" }; try { - const rows = await withTenant(brandId, (db) => + const rows = await withBrand(brandId, (db) => db .select({ storageKey: files.storageKey, @@ -138,7 +138,7 @@ export async function listImportHistory( createdAt: files.createdAt, }) .from(files) - .where(and(eq(files.tenantId, brandId), eq(files.purpose, "contact_import"))) + .where(and(eq(files.brandId, brandId), eq(files.purpose, "contact_import"))) .orderBy(desc(files.createdAt)) .limit(limit), ); diff --git a/src/actions/communications/segments.ts b/src/actions/communications/segments.ts index a63e055..d5d549b 100644 --- a/src/actions/communications/segments.ts +++ b/src/actions/communications/segments.ts @@ -2,7 +2,7 @@ import { eq } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; -import { withTenant } from "@/db/client"; +import { withBrand } from "@/db/client"; import { brandSettings } from "@/db/schema"; import type { AudienceRules } from "./campaigns"; @@ -56,22 +56,22 @@ function isSegment(v: unknown): v is Segment { } async function loadSegments(brandId: string): Promise { - const rows = await withTenant(brandId, (db) => + const rows = await withBrand(brandId, (db) => db .select({ flags: brandSettings.featureFlags }) .from(brandSettings) - .where(eq(brandSettings.tenantId, brandId)) + .where(eq(brandSettings.brandId, brandId)) .limit(1), ); return readSegments((rows[0]?.flags ?? null) as Record | null); } async function saveSegments(brandId: string, segments: Segment[]): Promise { - await withTenant(brandId, async (db) => { + await withBrand(brandId, async (db) => { const existing = await db .select({ flags: brandSettings.featureFlags }) .from(brandSettings) - .where(eq(brandSettings.tenantId, brandId)) + .where(eq(brandSettings.brandId, brandId)) .limit(1); const baseFlags = (existing[0]?.flags ?? {}) as Record; const nextFlags: Record = { @@ -81,7 +81,7 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise await db .update(brandSettings) .set({ featureFlags: nextFlags, updatedAt: new Date() }) - .where(eq(brandSettings.tenantId, brandId)); + .where(eq(brandSettings.brandId, brandId)); }); } diff --git a/src/actions/communications/send.ts b/src/actions/communications/send.ts index 36c7b78..7dc03ae 100644 --- a/src/actions/communications/send.ts +++ b/src/actions/communications/send.ts @@ -3,13 +3,13 @@ import { and, eq, sql, SQL } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; -import { withTenant, withPlatformAdmin } from "@/db/client"; +import { withBrand, withPlatformAdmin } from "@/db/client"; import { campaigns, customers } from "@/db/schema"; import type { AudienceRules } from "./campaigns"; export type AudiencePreviewResult = { count: number; - sample_customers: { id: string; email: string; name: string }[]; + sample_customers: { id: string; email: string; fullName: string | null }[]; }; /** @@ -36,23 +36,23 @@ export async function previewCampaignAudience( } try { - const rows = await withTenant(brandId, (db) => + const rows = await withBrand(brandId, (db) => db .select({ id: customers.id, email: customers.email, - name: customers.name, + fullName: customers.fullName, }) .from(customers) - .where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true))) + .where(and(eq(customers.brandId, brandId), eq(customers.emailOptIn, true))) .limit(5), ); - const countRows = await withTenant(brandId, (db) => + const countRows = await withBrand(brandId, (db) => db .select({ value: sql`count(*)::int` }) .from(customers) - .where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true))), + .where(and(eq(customers.brandId, brandId), eq(customers.emailOptIn, true))), ); return { @@ -60,7 +60,7 @@ export async function previewCampaignAudience( sample_customers: rows.map((r) => ({ id: r.id, email: r.email ?? "", - name: r.name, + fullName: r.fullName, })), }; } catch { @@ -153,11 +153,11 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis } const conds: SQL[] = [eq(campaigns.id, campaignId)]; - if (activeBrandId) conds.push(eq(campaigns.tenantId, activeBrandId)); + if (activeBrandId) conds.push(eq(campaigns.brandId, activeBrandId)); try { const updated = activeBrandId - ? await withTenant(activeBrandId, (db) => + ? await withBrand(activeBrandId, (db) => db .update(campaigns) .set({ status: "sent", sentAt: new Date() }) diff --git a/src/actions/communications/settings.ts b/src/actions/communications/settings.ts index a9c2e1a..7a6fb61 100644 --- a/src/actions/communications/settings.ts +++ b/src/actions/communications/settings.ts @@ -1,7 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; -import { withTenant } from "@/db/client"; +import { withBrand } from "@/db/client"; import { brandSettings } from "@/db/schema"; import { eq } from "drizzle-orm"; @@ -47,15 +47,15 @@ function readFlag( export async function getCommunicationSettings(brandId: string): Promise { try { - const rows = await withTenant(brandId, (db) => + const rows = await withBrand(brandId, (db) => db .select({ - tenantId: brandSettings.tenantId, + brandId: brandSettings.brandId, featureFlags: brandSettings.featureFlags, updatedAt: brandSettings.updatedAt, }) .from(brandSettings) - .where(eq(brandSettings.tenantId, brandId)) + .where(eq(brandSettings.brandId, brandId)) .limit(1), ); @@ -65,8 +65,8 @@ export async function getCommunicationSettings(brandId: string): Promise; return { - id: row.tenantId, - brand_id: row.tenantId, + id: row.brandId, + brand_id: row.brandId, default_sender_email: readFlag(flags, "comm_default_sender_email"), default_sender_name: readFlag(flags, "comm_default_sender_name"), reply_to_email: readFlag(flags, "comm_reply_to_email"), @@ -96,11 +96,11 @@ export async function upsertCommunicationSettings(params: { } try { - const updated = await withTenant(params.brand_id, async (db) => { + const updated = await withBrand(params.brand_id, async (db) => { const existing = await db .select({ flags: brandSettings.featureFlags }) .from(brandSettings) - .where(eq(brandSettings.tenantId, params.brand_id)) + .where(eq(brandSettings.brandId, params.brand_id)) .limit(1); const baseFlags = (existing[0]?.flags ?? {}) as Record; const nextFlags: Record = { @@ -115,9 +115,9 @@ export async function upsertCommunicationSettings(params: { const result = await db .update(brandSettings) .set({ featureFlags: nextFlags, updatedAt: new Date() }) - .where(eq(brandSettings.tenantId, params.brand_id)) + .where(eq(brandSettings.brandId, params.brand_id)) .returning({ - tenantId: brandSettings.tenantId, + brandId: brandSettings.brandId, updatedAt: brandSettings.updatedAt, }); return result[0] ?? null; @@ -128,8 +128,8 @@ export async function upsertCommunicationSettings(params: { } const settings: CommunicationSettings = { - id: updated.tenantId, - brand_id: updated.tenantId, + id: updated.brandId, + brand_id: updated.brandId, default_sender_email: params.sender_email ?? null, default_sender_name: params.sender_name ?? null, reply_to_email: params.reply_to_email ?? null, diff --git a/src/actions/communications/stop-blast.ts b/src/actions/communications/stop-blast.ts index d27ff9e..58690f0 100644 --- a/src/actions/communications/stop-blast.ts +++ b/src/actions/communications/stop-blast.ts @@ -2,7 +2,7 @@ import { and, eq, sql, SQL } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; -import { withTenant } from "@/db/client"; +import { withBrand } from "@/db/client"; import { customers, orders, campaigns, emailTemplates } from "@/db/schema"; export type StopBlastResult = @@ -37,8 +37,8 @@ export async function sendStopBlast(params: { } try { - const conds: SQL[] = [eq(customers.tenantId, params.brandId)]; - const recipientRows = await withTenant(params.brandId, async (db) => { + const conds: SQL[] = [eq(customers.brandId, params.brandId)]; + const recipientRows = await withBrand(params.brandId, async (db) => { // Distinct customers from the tenant's recent orders. const orderCustomers = await db .selectDistinct({ id: customers.id }) @@ -46,7 +46,7 @@ export async function sendStopBlast(params: { .innerJoin(orders, eq(orders.customerId, customers.id)) .where( and( - eq(orders.tenantId, params.brandId), + eq(orders.brandId, params.brandId), params.channel === "sms" ? eq(customers.smsOptIn, true) : params.channel === "email" @@ -81,11 +81,11 @@ export async function sendStopBlast(params: { // Persist a draft campaign for traceability. No template link — the // stop-blast content is supplied inline and not yet modeled. const bodyHtml = `

${escapeHtml(params.body)}

`; - const inserted = await withTenant(params.brandId, async (db) => { + const inserted = await withBrand(params.brandId, async (db) => { const template = await db .insert(emailTemplates) .values({ - tenantId: params.brandId, + brandId: params.brandId, name: `Stop blast ${new Date().toISOString()}`, subject: params.subject ?? "Pickup update", bodyHtml, @@ -96,7 +96,7 @@ export async function sendStopBlast(params: { const campaign = await db .insert(campaigns) .values({ - tenantId: params.brandId, + brandId: params.brandId, name: `Stop blast ${new Date().toISOString()}`, templateId: tplId, status: "sent", diff --git a/src/actions/communications/stop-messaging.ts b/src/actions/communications/stop-messaging.ts index 8d4986b..dd3905c 100644 --- a/src/actions/communications/stop-messaging.ts +++ b/src/actions/communications/stop-messaging.ts @@ -2,7 +2,7 @@ import { and, desc, eq, isNotNull } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; -import { withTenant } from "@/db/client"; +import { withBrand } from "@/db/client"; import { customers, orders, campaigns } from "@/db/schema"; /** @@ -57,13 +57,13 @@ export async function getStopMessagingData(params: { } try { - const [orderRows, campaignRows] = await withTenant(brandId, async (db) => { + const [orderRows, campaignRows] = await withBrand(brandId, async (db) => { const o = await db .select({ id: orders.id, status: orders.status, placedAt: orders.placedAt, - customerName: customers.name, + customerName: customers.fullName, customerEmail: customers.email, customerPhone: customers.phone, }) @@ -71,7 +71,7 @@ export async function getStopMessagingData(params: { .innerJoin(customers, eq(customers.id, orders.customerId)) .where( and( - eq(orders.tenantId, brandId), + eq(orders.brandId, brandId), isNotNull(customers.email), ), ) @@ -86,7 +86,7 @@ export async function getStopMessagingData(params: { updatedAt: campaigns.updatedAt, }) .from(campaigns) - .where(eq(campaigns.tenantId, brandId)) + .where(eq(campaigns.brandId, brandId)) .orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt)) .limit(10); @@ -97,7 +97,7 @@ export async function getStopMessagingData(params: { // schema; treat "fulfilled" status as picked up). const mappedOrders: StopOrder[] = orderRows.map((o) => ({ id: o.id, - customer_name: o.customerName, + customer_name: o.customerName ?? "", customer_email: o.customerEmail, customer_phone: o.customerPhone, pickup_complete: o.status === "fulfilled", diff --git a/src/actions/communications/templates.ts b/src/actions/communications/templates.ts index efee760..3c90441 100644 --- a/src/actions/communications/templates.ts +++ b/src/actions/communications/templates.ts @@ -3,7 +3,7 @@ import { and, eq } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; -import { withTenant, withPlatformAdmin } from "@/db/client"; +import { withBrand, withPlatformAdmin } from "@/db/client"; import { emailTemplates } from "@/db/schema"; export type TemplateType = "email_template" | "sms_template" | "internal_note"; @@ -20,7 +20,7 @@ export type CampaignType = "marketing" | "operational" | "transactional"; */ export type Template = { id: string; - tenant_id: string; + brand_id: string; name: string; subject: string; body_text: string; @@ -42,7 +42,7 @@ export type UpsertTemplateResult = { function rowToTemplate(row: typeof emailTemplates.$inferSelect): Template { return { id: row.id, - tenant_id: row.tenantId, + brand_id: row.brandId, name: row.name, subject: row.subject, body_text: stripHtml(row.bodyHtml), @@ -79,7 +79,7 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc try { const rows = activeBrandId - ? await withTenant(activeBrandId, (db) => + ? await withBrand(activeBrandId, (db) => db .select() .from(emailTemplates) @@ -121,7 +121,7 @@ export async function upsertTemplate(params: { try { const row = params.id - ? await withTenant(params.brand_id, async (db) => { + ? await withBrand(params.brand_id, async (db) => { const updated = await db .update(emailTemplates) .set({ @@ -133,17 +133,17 @@ export async function upsertTemplate(params: { .where( and( eq(emailTemplates.id, params.id!), - eq(emailTemplates.tenantId, params.brand_id), + eq(emailTemplates.brandId, params.brand_id), ), ) .returning(); return updated[0] ?? null; }) - : await withTenant(params.brand_id, async (db) => { + : await withBrand(params.brand_id, async (db) => { const inserted = await db .insert(emailTemplates) .values({ - tenantId: params.brand_id, + brandId: params.brand_id, name: params.name, subject: params.subject, bodyHtml, @@ -170,14 +170,14 @@ export async function getTemplateById(templateId: string): Promise