feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
This commit is contained in:
+24
-43
@@ -9,51 +9,18 @@
|
|||||||
NEXT_PUBLIC_BASE_URL=http://localhost:4000
|
NEXT_PUBLIC_BASE_URL=http://localhost:4000
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:4000
|
NEXT_PUBLIC_SITE_URL=http://localhost:4000
|
||||||
|
|
||||||
# ── Database (Postgres, direct — Supabase is being removed) ────────────────
|
# ── Database (Neon Postgres, direct pg driver) ─────────────────────────────
|
||||||
# Single connection string used by `pg.Pool` in src/lib/auth.ts and the
|
# Single connection string used by the pg Pool in src/lib/auth.ts and the
|
||||||
# admin-permissions / data-service layer. Format:
|
# admin-permissions / data-service layer. Format:
|
||||||
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
|
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
|
||||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
|
||||||
|
|
||||||
# ── Auth.js (NextAuth v5) ───────────────────────────────────────────────────
|
# ── Neon Auth (Better Auth) ───────────────────────────────────────────────
|
||||||
# Generate with: npx auth secret
|
# Get these from: neonctl neon-auth status
|
||||||
# Or: openssl rand -base64 32
|
# Base URL: "Auth Base URL" field
|
||||||
AUTH_SECRET=replace-me-with-a-32-byte-base64-string
|
# Cookie secret: openssl rand -base64 32 (min 32 chars)
|
||||||
|
NEON_AUTH_BASE_URL=https://your-branch.neonauth.region.aws.neon.tech/neondb/auth
|
||||||
# Base URL used to build OAuth callback URLs. In dev:
|
NEON_AUTH_COOKIE_SECRET=replace-me-with-a-32-char-secret
|
||||||
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=
|
|
||||||
|
|
||||||
# ── Stripe ─────────────────────────────────────────────────────────────────
|
# ── Stripe ─────────────────────────────────────────────────────────────────
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
@@ -73,7 +40,7 @@ STRIPE_PRICE_SMS_CAMPAIGNS=
|
|||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
RESEND_WEBHOOK_SECRET=
|
RESEND_WEBHOOK_SECRET=
|
||||||
|
|
||||||
# ── AI providers ────────────────────────────────────────────────────────────
|
# ── AI providers ──────────────────────────────────────────────────────────
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
GOOGLE_API_KEY=
|
GOOGLE_API_KEY=
|
||||||
@@ -81,9 +48,23 @@ XAI_API_KEY=
|
|||||||
MINIMAX_API_KEY=
|
MINIMAX_API_KEY=
|
||||||
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||||
|
|
||||||
# ── Square (optional) ───────────────────────────────────────────────────────
|
# ── Square (optional) ─────────────────────────────────────────────────────
|
||||||
SQUARE_APP_SECRET=
|
SQUARE_APP_SECRET=
|
||||||
SQUARE_ENVIRONMENT=sandbox
|
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 / automation ───────────────────────────────────────────────────────
|
||||||
CRON_SECRET=replace-me-with-a-random-string
|
CRON_SECRET=replace-me-with-a-random-string
|
||||||
|
|||||||
+90
-247
@@ -15,264 +15,104 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
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
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
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
|
- name: Build
|
||||||
env:
|
env:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
|
||||||
# Auth.js v5 (NextAuth). Fall back to Better Auth names if the
|
NEON_AUTH_BASE_URL: ${{ secrets.NEON_AUTH_BASE_URL }}
|
||||||
# Gitea secret hasn't been renamed yet.
|
NEON_AUTH_COOKIE_SECRET: ${{ secrets.NEON_AUTH_COOKIE_SECRET }}
|
||||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
|
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||||
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
|
AUTH_URL: ${{ secrets.AUTH_URL }}
|
||||||
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
|
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }}
|
||||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||||
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
||||||
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
|
||||||
# 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
|
|
||||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
|
STRIPE_PRICE_STARTER: ${{ secrets.STRIPE_PRICE_STARTER }}
|
||||||
|
STRIPE_PRICE_FARM: ${{ secrets.STRIPE_PRICE_FARM }}
|
||||||
# Resend
|
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_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
|
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||||
|
MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }}
|
||||||
# AI providers
|
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_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
|
run: npm run build
|
||||||
# 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
|
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
|
||||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
NEON_AUTH_BASE_URL: ${{ secrets.NEON_AUTH_BASE_URL }}
|
||||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
NEON_AUTH_COOKIE_SECRET: ${{ secrets.NEON_AUTH_COOKIE_SECRET }}
|
||||||
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||||
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
AUTH_URL: ${{ secrets.AUTH_URL }}
|
||||||
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }}
|
||||||
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||||
# Auth.js v5 (with Better Auth fallback for the secret name)
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||||
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 }}
|
|
||||||
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
||||||
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
|
||||||
# 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
|
|
||||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
|
STRIPE_PRICE_STARTER: ${{ secrets.STRIPE_PRICE_STARTER }}
|
||||||
|
STRIPE_PRICE_FARM: ${{ secrets.STRIPE_PRICE_FARM }}
|
||||||
# Resend
|
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_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
|
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||||
|
MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }}
|
||||||
# AI
|
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_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
|
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||||
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
|
||||||
run: |
|
run: |
|
||||||
APP_DIR=/home/tyler/route-commerce
|
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 "DATABASE_URL=%s\n" "$DATABASE_URL"
|
||||||
printf "NEXT_PUBLIC_API_URL=%s\n" "$NEXT_PUBLIC_API_URL"
|
printf "NEXT_PUBLIC_SITE_URL=%s\n" "$NEXT_PUBLIC_SITE_URL"
|
||||||
printf "POSTGRES_USER=%s\n" "$POSTGRES_USER"
|
printf "NEON_AUTH_BASE_URL=%s\n" "$NEON_AUTH_BASE_URL"
|
||||||
printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD"
|
printf "NEON_AUTH_COOKIE_SECRET=%s\n" "$NEON_AUTH_COOKIE_SECRET"
|
||||||
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 "AUTH_SECRET=%s\n" "$AUTH_SECRET"
|
printf "AUTH_SECRET=%s\n" "$AUTH_SECRET"
|
||||||
printf "AUTH_URL=%s\n" "$AUTH_URL"
|
printf "AUTH_URL=%s\n" "$AUTH_URL"
|
||||||
printf "NEXT_PUBLIC_AUTH_URL=%s\n" "$NEXT_PUBLIC_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 "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
|
||||||
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN"
|
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN"
|
||||||
printf "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS"
|
printf "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS"
|
||||||
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
|
printf "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=%s\n" "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY"
|
||||||
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 "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
|
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
|
||||||
printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET"
|
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_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_API_KEY=%s\n" "$MINIMAX_API_KEY"
|
||||||
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL"
|
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
|
} > $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 .next/ $APP_DIR/.next/
|
||||||
rsync -a --delete public/ $APP_DIR/public/
|
rsync -a --delete public/ $APP_DIR/public/
|
||||||
cp package.json $APP_DIR/
|
cp package.json $APP_DIR/
|
||||||
cp deploy/docker-compose.yml $APP_DIR/
|
cp next.config.ts $APP_DIR/ 2>/dev/null || true
|
||||||
cp -r supabase/ $APP_DIR/
|
|
||||||
cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true
|
|
||||||
|
|
||||||
# Install production deps only
|
# Install production deps only
|
||||||
cd $APP_DIR
|
cd $APP_DIR
|
||||||
npm install --omit=dev
|
npm install --omit=dev
|
||||||
|
|
||||||
# Start or restart PM2 process
|
# Start or restart PM2
|
||||||
if pm2 describe route-commerce > /dev/null 2>&1; then
|
if pm2 describe route-commerce > /dev/null 2>&1; then
|
||||||
pm2 restart route-commerce
|
pm2 restart route-commerce
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ playwright-report/
|
|||||||
# IDE / local config
|
# IDE / local config
|
||||||
.mcp.json
|
.mcp.json
|
||||||
.env*
|
.env*
|
||||||
|
public/videos/tuxedo-hero.mp4
|
||||||
|
|||||||
@@ -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.
|
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
|
### 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`):**
|
**Auth flow:**
|
||||||
- **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.
|
1. User signs in via `/api/auth/sign-in` → Neon Auth validates credentials → session cookie set
|
||||||
- **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.
|
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:
|
**Key files:**
|
||||||
- `dev_session=platform_admin` — full access, all brands
|
- `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset)
|
||||||
- `dev_session=brand_admin` — full access to assigned brand only
|
- `src/auth.config.ts` — Edge-safe config (baseUrl, cookieSecret)
|
||||||
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
|
- `src/middleware.ts` — Edge-level route protection
|
||||||
- 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.
|
- `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.<uid>` — 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.
|
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`)
|
| Route | Method | Purpose |
|
||||||
- ✅ `src/lib/auth.ts` + `src/app/api/auth/[...nextauth]/route.ts` in place
|
|-------|--------|---------|
|
||||||
- ✅ Google + Credentials (Supabase-backed) providers configured
|
| `/api/auth/sign-in` | POST | Email/password sign-in |
|
||||||
- ✅ `getAdminUser()` reads from `auth()` session
|
| `/api/auth/forgot-password` | POST | Request password reset email |
|
||||||
- ✅ Middleware uses `auth()` wrapper
|
| `/api/auth/reset-password` | POST | Set new password (after reset link clicked) |
|
||||||
- ✅ Old `/api/login`, `/api/logout`, `/api/auth/uid`, `/api/set-auth-cookie` removed
|
| `/api/auth/[...nextauth]` | GET/POST | Neon Auth handler (sign-out, OAuth callbacks, etc.) |
|
||||||
- ⏳ 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
|
|
||||||
|
|
||||||
### Server Actions Pattern
|
### Server Actions Pattern
|
||||||
|
|
||||||
All database writes go through server actions in `src/actions/`. These:
|
All database writes go through server actions in `src/actions/`. These:
|
||||||
1. Call `getAdminUser()` to verify auth
|
1. Call `getAdminUser()` to verify auth
|
||||||
2. Check role/permission flags (`can_manage_orders`, etc.)
|
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 }`)
|
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.
|
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 |
|
| 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` |
|
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
|
||||||
| Middleware (route protection) | `src/middleware.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}/`) |
|
| 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 pages | `src/app/admin/[module]/page.tsx` |
|
||||||
| Admin client components | `src/components/admin/*.tsx` |
|
| Admin client components | `src/components/admin/*.tsx` |
|
||||||
| Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) |
|
| Migrations | `db/migrations/` |
|
||||||
| Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) |
|
| Postgres pool / driver | `src/lib/db.ts` (TBD) |
|
||||||
| Email templates | `src/lib/email-templates.ts` |
|
| Email templates | `src/lib/email-templates.ts` |
|
||||||
| Date formatting | `src/lib/format-date.ts` |
|
| Date formatting | `src/lib/format-date.ts` |
|
||||||
| Feature flags | `src/lib/feature-flags.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.
|
- **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.
|
- **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.
|
- **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.
|
- **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`.
|
- **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.
|
||||||
@@ -170,3 +170,49 @@ Controls whether to use Square sandbox or production.
|
|||||||
- **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type.
|
- **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.
|
- **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.
|
- **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.
|
||||||
+23
-22
@@ -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
|
* The app connects to Postgres directly via the `pg` driver. Drizzle sits
|
||||||
* on top, providing typed queries. The `withTenant` wrapper is the only
|
* on top, providing typed queries. The `withBrand` wrapper is the only
|
||||||
* sanctioned way to run a tenant-scoped query — it sets the
|
* sanctioned way to run a brand-scoped query — it sets the
|
||||||
* `app.current_tenant_id` GUC transaction-locally, and the database's
|
* `app.current_brand_id` GUC transaction-locally, and the database's
|
||||||
* RLS policies enforce tenant isolation even if application code forgets
|
* RLS policies enforce brand isolation even if application code forgets
|
||||||
* a `WHERE tenant_id = $1`.
|
* a `WHERE brand_id = $1`.
|
||||||
*
|
*
|
||||||
* Usage (read):
|
* Usage (read):
|
||||||
* const products = await withTenant(tenantId, (db) =>
|
* const products = await withBrand(brandId, (db) =>
|
||||||
* db.select().from(productsTable).where(eq(productsTable.active, true)),
|
* db.select().from(productsTable).where(eq(productsTable.active, true)),
|
||||||
* );
|
* );
|
||||||
*
|
*
|
||||||
* Usage (platform admin — sees all tenants):
|
* Usage (platform admin — sees all brands):
|
||||||
* const allTenants = await withPlatformAdmin((db) =>
|
* const allBrands = await withPlatformAdmin((db) =>
|
||||||
* db.select().from(tenantsTable),
|
* 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));
|
* 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,
|
* is responsible for RLS bypass (e.g. for the `plans` and `add_ons` tables,
|
||||||
* which are not tenant-scoped). For tenant-scoped reads, prefer
|
* which are not brand-scoped). For brand-scoped reads, prefer
|
||||||
* `withTenant` or `withPlatformAdmin`.
|
* `withBrand` or `withPlatformAdmin`.
|
||||||
*/
|
*/
|
||||||
export async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
|
export async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
|
||||||
const client = await getPool().connect();
|
const client = await getPool().connect();
|
||||||
@@ -73,22 +73,22 @@ export async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run `fn` inside a transaction with the current tenant id set as a
|
* Run `fn` inside a transaction with the current brand id set as a
|
||||||
* transaction-local GUC. RLS policies on tenant-scoped tables will allow
|
* transaction-local GUC. RLS policies on brand-scoped tables will allow
|
||||||
* reads/writes only for rows where `tenant_id` matches. Pass `null` to
|
* reads/writes only for rows where `brand_id` matches. Pass `null` to
|
||||||
* fail open (don't set the GUC) — only useful for the migrations
|
* fail open (don't set the GUC) — only useful for the migrations
|
||||||
* themselves, never for app code.
|
* themselves, never for app code.
|
||||||
*/
|
*/
|
||||||
export async function withTenant<T>(
|
export async function withBrand<T>(
|
||||||
tenantId: string,
|
brandId: string,
|
||||||
fn: (db: Db) => Promise<T>,
|
fn: (db: Db) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return runInTransaction(async (client) => {
|
return runInTransaction(async (client) => {
|
||||||
// set_config(setting, value, is_local) — is_local=true makes it
|
// set_config(setting, value, is_local) — is_local=true makes it
|
||||||
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
|
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
|
||||||
// leaks across pooled connections.
|
// leaks across pooled connections.
|
||||||
await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [
|
await client.query("SELECT set_config('app.current_brand_id', $1, true)", [
|
||||||
tenantId,
|
brandId,
|
||||||
]);
|
]);
|
||||||
await client.query("SELECT set_config('app.platform_admin', 'false', true)");
|
await client.query("SELECT set_config('app.platform_admin', 'false', true)");
|
||||||
const db = drizzle(client, { schema });
|
const db = drizzle(client, { schema });
|
||||||
@@ -97,13 +97,14 @@ export async function withTenant<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
* Use sparingly — typically only in the /admin/platform routes.
|
||||||
*/
|
*/
|
||||||
export async function withPlatformAdmin<T>(
|
export async function withPlatformAdmin<T>(
|
||||||
fn: (db: Db) => Promise<T>,
|
fn: (db: Db) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return runInTransaction(async (client) => {
|
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)");
|
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
|
||||||
const db = drizzle(client, { schema });
|
const db = drizzle(client, { schema });
|
||||||
return fn(db);
|
return fn(db);
|
||||||
|
|||||||
+1666
-381
File diff suppressed because it is too large
Load Diff
+4
-7
@@ -9,19 +9,16 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
index,
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { tenants } from "./tenants";
|
import { brands } from "./brands";
|
||||||
import { users } from "./tenants";
|
|
||||||
|
|
||||||
export const auditLog = pgTable(
|
export const auditLog = pgTable(
|
||||||
"audit_log",
|
"audit_log",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
tenantId: uuid("tenant_id").references(() => tenants.id, {
|
brandId: uuid("brand_id").references(() => brands.id, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
userId: uuid("user_id").references(() => users.id, {
|
userId: uuid("user_id"), // FK to neon_auth.user(id) — plain UUID, enforced at DB level
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
action: text("action").notNull(),
|
action: text("action").notNull(),
|
||||||
targetType: text("target_type"),
|
targetType: text("target_type"),
|
||||||
targetId: uuid("target_id"),
|
targetId: uuid("target_id"),
|
||||||
@@ -31,7 +28,7 @@ export const auditLog = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
tenantIdx: index("audit_log_tenant_idx").on(t.tenantId, t.createdAt),
|
brandIdx: index("audit_log_brand_idx").on(t.brandId, t.createdAt),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+19
-43
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Billing tables: plans, add-ons, subscriptions, tenant_add_ons.
|
* Billing: plans, add_ons, brand_add_ons.
|
||||||
* Source of truth: `db/migrations/0001_init.sql`.
|
* Source: `db/migrations/0001_init.sql`.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -11,17 +11,13 @@ import {
|
|||||||
jsonb,
|
jsonb,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import {
|
import { brands } from "./brands";
|
||||||
planCodeEnum,
|
|
||||||
addOnCodeEnum,
|
|
||||||
subscriptionStatusEnum,
|
|
||||||
addOnStatusEnum,
|
|
||||||
} from "./enums";
|
|
||||||
import { tenants } from "./tenants";
|
|
||||||
|
|
||||||
export const plans = pgTable("plans", {
|
export const plans = pgTable("plans", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
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(),
|
name: text("name").notNull(),
|
||||||
monthlyPriceCents: integer("monthly_price_cents").notNull(),
|
monthlyPriceCents: integer("monthly_price_cents").notNull(),
|
||||||
maxUsers: integer("max_users").notNull(),
|
maxUsers: integer("max_users").notNull(),
|
||||||
@@ -35,7 +31,12 @@ export const plans = pgTable("plans", {
|
|||||||
|
|
||||||
export const addOns = pgTable("add_ons", {
|
export const addOns = pgTable("add_ons", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
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(),
|
name: text("name").notNull(),
|
||||||
monthlyPriceCents: integer("monthly_price_cents").notNull(),
|
monthlyPriceCents: integer("monthly_price_cents").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
@@ -44,37 +45,17 @@ export const addOns = pgTable("add_ons", {
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subscriptions = pgTable("subscriptions", {
|
export const brandAddOns = pgTable(
|
||||||
tenantId: uuid("tenant_id")
|
"brand_add_ons",
|
||||||
.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",
|
|
||||||
{
|
{
|
||||||
tenantId: uuid("tenant_id")
|
brandId: uuid("brand_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
addOnId: uuid("add_on_id")
|
addOnId: uuid("add_on_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => addOns.id, { onDelete: "cascade" }),
|
.references(() => addOns.id, { onDelete: "cascade" }),
|
||||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||||
status: text("status", { enum: addOnStatusEnum })
|
status: text("status", { enum: ["active", "canceled"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("active"),
|
.default("active"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
@@ -82,15 +63,10 @@ export const tenantAddOns = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
pk: primaryKey({ columns: [t.tenantId, t.addOnId] }),
|
pk: primaryKey({ columns: [t.brandId, t.addOnId] }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export type Plan = typeof plans.$inferSelect;
|
export type Plan = typeof plans.$inferSelect;
|
||||||
export type NewPlan = typeof plans.$inferInsert;
|
|
||||||
export type AddOn = typeof addOns.$inferSelect;
|
export type AddOn = typeof addOns.$inferSelect;
|
||||||
export type NewAddOn = typeof addOns.$inferInsert;
|
export type BrandAddOn = typeof brandAddOns.$inferSelect;
|
||||||
export type Subscription = typeof subscriptions.$inferSelect;
|
|
||||||
export type NewSubscription = typeof subscriptions.$inferInsert;
|
|
||||||
export type TenantAddOn = typeof tenantAddOns.$inferSelect;
|
|
||||||
export type NewTenantAddOn = typeof tenantAddOns.$inferInsert;
|
|
||||||
|
|||||||
+71
-23
@@ -1,28 +1,76 @@
|
|||||||
/**
|
/**
|
||||||
* Brand settings. One row per tenant. Source of truth:
|
* Brand settings + brand features. Source: `db/migrations/0001_init.sql`.
|
||||||
* `db/migrations/0001_init.sql`.
|
|
||||||
*/
|
*/
|
||||||
import { pgTable, uuid, text, jsonb, timestamp } from "drizzle-orm/pg-core";
|
import {
|
||||||
import { tenants } from "./tenants";
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
jsonb,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { brands } from "./brands";
|
||||||
|
|
||||||
export const brandSettings = pgTable("brand_settings", {
|
export const brandSettings = pgTable(
|
||||||
tenantId: uuid("tenant_id")
|
"brand_settings",
|
||||||
.primaryKey()
|
{
|
||||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
brandId: uuid("brand_id")
|
||||||
brandName: text("brand_name").notNull(),
|
.primaryKey()
|
||||||
tagline: text("tagline"),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
aboutHtml: text("about_html"),
|
legalBusinessName: text("legal_business_name"),
|
||||||
primaryColor: text("primary_color").default("#0F766E"),
|
phone: text("phone"),
|
||||||
logoStorageKey: text("logo_storage_key"),
|
email: text("email"),
|
||||||
heroStorageKey: text("hero_storage_key"),
|
websiteUrl: text("website_url"),
|
||||||
contactEmail: text("contact_email"),
|
streetAddress: text("street_address"),
|
||||||
contactPhone: text("contact_phone"),
|
city: text("city"),
|
||||||
customFooterText: text("custom_footer_text"),
|
state: text("state"),
|
||||||
featureFlags: jsonb("feature_flags").notNull().default({}),
|
postalCode: text("postal_code"),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
country: text("country").default("US"),
|
||||||
.notNull()
|
logoUrl: text("logo_url"),
|
||||||
.defaultNow(),
|
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 BrandSettings = typeof brandSettings.$inferSelect;
|
||||||
export type NewBrandSettings = typeof brandSettings.$inferInsert;
|
export type BrandFeature = typeof brandFeatures.$inferSelect;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
+20
-13
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Customers. Source of truth: `db/migrations/0001_init.sql`.
|
* Customers. Source: `db/migrations/0001_init.sql`.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -7,25 +7,35 @@ import {
|
|||||||
text,
|
text,
|
||||||
boolean,
|
boolean,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
varchar,
|
||||||
|
bigint,
|
||||||
|
jsonb,
|
||||||
index,
|
index,
|
||||||
uniqueIndex,
|
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { brands } from "./brands";
|
||||||
import { tenants } from "./tenants";
|
|
||||||
|
|
||||||
export const customers = pgTable(
|
export const customers = pgTable(
|
||||||
"customers",
|
"customers",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
tenantId: uuid("tenant_id")
|
brandId: uuid("brand_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
|
||||||
email: text("email"),
|
email: text("email"),
|
||||||
phone: text("phone"),
|
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),
|
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 })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
@@ -34,10 +44,7 @@ export const customers = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
tenantIdx: index("customers_tenant_idx").on(t.tenantId),
|
brandIdx: index("customers_brand_idx").on(t.brandId),
|
||||||
emailIdx: uniqueIndex("customers_tenant_email_idx")
|
|
||||||
.on(t.tenantId, t.email)
|
|
||||||
.where(sql`${t.email} IS NOT NULL`),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+6
-9
@@ -5,33 +5,30 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
uuid,
|
uuid,
|
||||||
text,
|
text,
|
||||||
bigint,
|
integer,
|
||||||
timestamp,
|
timestamp,
|
||||||
index,
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { tenants } from "./tenants";
|
import { brands } from "./brands";
|
||||||
import { users } from "./tenants";
|
|
||||||
|
|
||||||
export const files = pgTable(
|
export const files = pgTable(
|
||||||
"files",
|
"files",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
tenantId: uuid("tenant_id").references(() => tenants.id, {
|
brandId: uuid("brand_id").references(() => brands.id, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
storageKey: text("storage_key").notNull().unique(),
|
storageKey: text("storage_key").notNull().unique(),
|
||||||
mimeType: text("mime_type").notNull(),
|
mimeType: text("mime_type").notNull(),
|
||||||
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
|
sizeBytes: integer("size_bytes").notNull(),
|
||||||
purpose: text("purpose"),
|
purpose: text("purpose"),
|
||||||
uploadedBy: uuid("uploaded_by").references(() => users.id, {
|
uploadedBy: uuid("uploaded_by"), // FK to neon_auth.user(id) — plain UUID, enforced at DB level
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
tenantIdx: index("files_tenant_idx").on(t.tenantId),
|
brandIdx: index("files_brand_idx").on(t.brandId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+8
-6
@@ -1,17 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Schema barrel. Re-exports every Drizzle table + inferred row type.
|
* Schema barrel. Re-exports every Drizzle table + inferred row type.
|
||||||
*
|
* Source of truth: `db/migrations/0001_init.sql`.
|
||||||
* Usage:
|
|
||||||
* import { products, type Product } from "@/db/schema";
|
|
||||||
*/
|
*/
|
||||||
export * from "./enums";
|
export * from "./brands";
|
||||||
export * from "./tenants";
|
|
||||||
export * from "./billing";
|
export * from "./billing";
|
||||||
export * from "./products";
|
export * from "./products";
|
||||||
export * from "./stops";
|
export * from "./stops";
|
||||||
export * from "./customers";
|
export * from "./customers";
|
||||||
export * from "./orders";
|
export * from "./orders";
|
||||||
export * from "./brand";
|
export * from "./brand";
|
||||||
|
export * from "./wholesale";
|
||||||
|
export * from "./water-log";
|
||||||
|
export * from "./communications";
|
||||||
export * from "./marketing";
|
export * from "./marketing";
|
||||||
|
export * from "./time-tracking";
|
||||||
|
export * from "./shipping";
|
||||||
|
export * from "./support";
|
||||||
export * from "./files";
|
export * from "./files";
|
||||||
export * from "./audit";
|
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ import {
|
|||||||
index,
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { campaignStatusEnum } from "./enums";
|
import { campaignStatusEnum } from "./enums";
|
||||||
import { tenants } from "./tenants";
|
import { brands } from "./brands";
|
||||||
|
|
||||||
export const emailTemplates = pgTable(
|
export const emailTemplates = pgTable(
|
||||||
"email_templates",
|
"email_templates",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
tenantId: uuid("tenant_id")
|
brandId: uuid("brand_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
subject: text("subject").notNull(),
|
subject: text("subject").notNull(),
|
||||||
bodyHtml: text("body_html").notNull(),
|
bodyHtml: text("body_html").notNull(),
|
||||||
@@ -31,7 +31,7 @@ export const emailTemplates = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(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",
|
"campaigns",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
tenantId: uuid("tenant_id")
|
brandId: uuid("brand_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
templateId: uuid("template_id").references((): any => emailTemplates.id, {
|
templateId: uuid("template_id").references((): any => emailTemplates.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
@@ -60,8 +60,8 @@ export const campaigns = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
tenantIdx: index("campaigns_tenant_idx").on(t.tenantId),
|
brandIdx: index("campaigns_brand_idx").on(t.brandId),
|
||||||
statusIdx: index("campaigns_status_idx").on(t.tenantId, t.status),
|
statusIdx: index("campaigns_status_idx").on(t.brandId, t.status),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+28
-10
@@ -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 {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -9,24 +9,36 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
index,
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { orderStatusEnum, fulfillmentEnum, itemFulfillmentEnum } from "./enums";
|
import { brands } from "./brands";
|
||||||
import { tenants } from "./tenants";
|
|
||||||
import { customers } from "./customers";
|
import { customers } from "./customers";
|
||||||
|
import { stops } from "./stops";
|
||||||
import { products } from "./products";
|
import { products } from "./products";
|
||||||
|
|
||||||
export const orders = pgTable(
|
export const orders = pgTable(
|
||||||
"orders",
|
"orders",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
tenantId: uuid("tenant_id")
|
brandId: uuid("brand_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
customerId: uuid("customer_id").references(() => customers.id, {
|
customerId: uuid("customer_id").references(() => customers.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
stopId: uuid("stop_id").references(() => stops.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
totalCents: integer("total_cents").notNull().default(0),
|
totalCents: integer("total_cents").notNull().default(0),
|
||||||
status: text("status", { enum: orderStatusEnum }).notNull().default("pending"),
|
status: text("status", {
|
||||||
fulfillment: text("fulfillment", { enum: fulfillmentEnum }).notNull(),
|
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"),
|
notes: text("notes"),
|
||||||
placedAt: timestamp("placed_at", { withTimezone: true })
|
placedAt: timestamp("placed_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -36,9 +48,10 @@ export const orders = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(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),
|
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(),
|
quantity: integer("quantity").notNull(),
|
||||||
priceCents: integer("price_cents").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) => ({
|
(t) => ({
|
||||||
orderIdx: index("order_items_order_idx").on(t.orderId),
|
orderIdx: index("order_items_order_idx").on(t.orderId),
|
||||||
|
|||||||
+21
-9
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Products + product_images. Source of truth: `db/migrations/0001_init.sql`.
|
* Products. Source: `db/migrations/0001_init.sql`.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -10,21 +10,30 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
index,
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { tenants } from "./tenants";
|
import { brands } from "./brands";
|
||||||
|
|
||||||
export const products = pgTable(
|
export const products = pgTable(
|
||||||
"products",
|
"products",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
tenantId: uuid("tenant_id")
|
brandId: uuid("brand_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
sku: text("sku"),
|
||||||
|
type: text("type", { enum: ["standard", "wholesale", "both"] })
|
||||||
|
.notNull()
|
||||||
|
.default("standard"),
|
||||||
priceCents: integer("price_cents").notNull(),
|
priceCents: integer("price_cents").notNull(),
|
||||||
inventory: integer("inventory").notNull().default(0),
|
inventory: integer("inventory").notNull().default(0),
|
||||||
unit: text("unit"),
|
unit: text("unit"),
|
||||||
active: boolean("active").notNull().default(true),
|
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 })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
@@ -33,11 +42,16 @@ export const products = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
tenantIdx: index("products_tenant_idx").on(t.tenantId),
|
brandIdx: index("products_brand_idx").on(t.brandId),
|
||||||
activeIdx: index("products_active_idx").on(t.tenantId, t.active),
|
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(
|
export const productImages = pgTable(
|
||||||
"product_images",
|
"product_images",
|
||||||
{
|
{
|
||||||
@@ -53,11 +67,9 @@ export const productImages = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(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 ProductImage = typeof productImages.$inferSelect;
|
||||||
export type NewProductImage = typeof productImages.$inferInsert;
|
export type NewProductImage = typeof productImages.$inferInsert;
|
||||||
|
|||||||
@@ -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;
|
||||||
+55
-11
@@ -1,28 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* Stops. Source of truth: `db/migrations/0001_init.sql`.
|
* Stops + Locations. Source: `db/migrations/0001_init.sql`.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
uuid,
|
uuid,
|
||||||
text,
|
text,
|
||||||
jsonb,
|
date,
|
||||||
|
boolean,
|
||||||
timestamp,
|
timestamp,
|
||||||
index,
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { stopStatusEnum } from "./enums";
|
import { brands } from "./brands";
|
||||||
import { tenants } from "./tenants";
|
|
||||||
|
|
||||||
export const stops = pgTable(
|
export const stops = pgTable(
|
||||||
"stops",
|
"stops",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
tenantId: uuid("tenant_id")
|
brandId: uuid("brand_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => tenants.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
address: text("address").notNull(),
|
location: text("location").notNull(),
|
||||||
schedule: jsonb("schedule").notNull().default([]),
|
address: text("address"),
|
||||||
status: text("status", { enum: stopStatusEnum }).notNull().default("active"),
|
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"),
|
notes: text("notes"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -32,10 +43,43 @@ export const stops = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
tenantIdx: index("stops_tenant_idx").on(t.tenantId),
|
brandIdx: index("stops_brand_idx").on(t.brandId),
|
||||||
statusIdx: index("stops_status_idx").on(t.tenantId, t.status),
|
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 Stop = typeof stops.$inferSelect;
|
||||||
export type NewStop = typeof stops.$inferInsert;
|
export type NewStop = typeof stops.$inferInsert;
|
||||||
|
export type Location = typeof locations.$inferSelect;
|
||||||
|
export type NewLocation = typeof locations.$inferInsert;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
+42
-193
@@ -6,36 +6,20 @@
|
|||||||
* npm run db:seed
|
* npm run db:seed
|
||||||
*
|
*
|
||||||
* Populates:
|
* Populates:
|
||||||
* - 3 plans (Starter / Farm / Enterprise)
|
* - 2 brands (Tuxedo, Indian River Direct)
|
||||||
* - 6 add-ons
|
* - brand_settings per brand
|
||||||
* - 2 tenants (Tuxedo, Indian River Direct)
|
* - sample products, stops, customers per brand
|
||||||
* - 1 platform-admin user + 1 brand_admin per tenant
|
* - sample communication templates + a draft campaign
|
||||||
* - brand_settings per tenant
|
*
|
||||||
* - sample products, stops, customers per tenant
|
* NOTE: Admin users are managed by Neon Auth. To create an admin user:
|
||||||
* - sample email templates + a draft campaign
|
* 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 (<admin_user_id>, <brand_id>, 'brand_admin');
|
||||||
*/
|
*/
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { Pool } from "pg";
|
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({
|
const pool = new Pool({
|
||||||
connectionString:
|
connectionString:
|
||||||
process.env.DATABASE_ADMIN_URL ??
|
process.env.DATABASE_ADMIN_URL ??
|
||||||
@@ -48,52 +32,8 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
await client.query("BEGIN");
|
await client.query("BEGIN");
|
||||||
|
|
||||||
// ── Plans ────────────────────────────────────────────────────────────
|
// ── Brands ──────────────────────────────────────────────────────────────
|
||||||
const planRows = [
|
const brandsData = [
|
||||||
{ 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 = [
|
|
||||||
{
|
{
|
||||||
slug: "tuxedo",
|
slug: "tuxedo",
|
||||||
name: "Tuxedo Citrus",
|
name: "Tuxedo Citrus",
|
||||||
@@ -104,8 +44,6 @@ async function main() {
|
|||||||
primaryColor: "#F59E0B",
|
primaryColor: "#F59E0B",
|
||||||
contactEmail: "hello@tuxedocitrus.example",
|
contactEmail: "hello@tuxedocitrus.example",
|
||||||
contactPhone: "(555) 010-2200",
|
contactPhone: "(555) 010-2200",
|
||||||
planCode: "farm",
|
|
||||||
addOns: ["wholesale_portal", "harvest_reach"],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "indian-river-direct",
|
slug: "indian-river-direct",
|
||||||
@@ -117,89 +55,36 @@ async function main() {
|
|||||||
primaryColor: "#0F766E",
|
primaryColor: "#0F766E",
|
||||||
contactEmail: "orders@indianriverdirect.example",
|
contactEmail: "orders@indianriverdirect.example",
|
||||||
contactPhone: "(555) 010-3300",
|
contactPhone: "(555) 010-3300",
|
||||||
planCode: "starter",
|
|
||||||
addOns: ["wholesale_portal"],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const t of tenantsData) {
|
for (const b of brandsData) {
|
||||||
// Upsert tenant
|
// Upsert brand
|
||||||
const tenantRes = await client.query<{ id: string }>(
|
const brandRes = await client.query<{ id: string }>(
|
||||||
`INSERT INTO tenants (name, slug, status, trial_ends_at)
|
`INSERT INTO brands (name, slug, plan_tier)
|
||||||
VALUES ($1, $2, 'active', now() + interval '30 days')
|
VALUES ($1, $2, 'starter')
|
||||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[t.name, t.slug],
|
[b.name, b.slug],
|
||||||
);
|
);
|
||||||
const tenantId = tenantRes.rows[0].id;
|
const brandId = brandRes.rows[0].id;
|
||||||
|
|
||||||
// Plan + subscription
|
// Brand settings (PK is brand_id)
|
||||||
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)
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO brand_settings
|
`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)
|
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,
|
brand_name = EXCLUDED.brand_name,
|
||||||
tagline = EXCLUDED.tagline,
|
tagline = EXCLUDED.tagline,
|
||||||
about_html = EXCLUDED.about_html,
|
about_html = EXCLUDED.about_html,
|
||||||
primary_color = EXCLUDED.primary_color,
|
primary_color = EXCLUDED.primary_color,
|
||||||
contact_email = EXCLUDED.contact_email,
|
contact_email = EXCLUDED.contact_email,
|
||||||
contact_phone = EXCLUDED.contact_phone`,
|
contact_phone = EXCLUDED.contact_phone`,
|
||||||
[
|
[brandId, b.brandName, b.tagline, b.aboutHtml, b.primaryColor, b.contactEmail, b.contactPhone],
|
||||||
tenantId, t.brandName, t.tagline, t.aboutHtml,
|
|
||||||
t.primaryColor, t.contactEmail, t.contactPhone,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Brand admin user
|
// Sample products
|
||||||
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)
|
|
||||||
const products = [
|
const products = [
|
||||||
{ name: "Ruby Red Grapefruit", price: 2400, unit: "40 lb case", desc: "Sweet, juicy, seedless." },
|
{ 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." },
|
{ 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) {
|
for (const p of products) {
|
||||||
await client.query(
|
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
|
SELECT $1, $2, $3, $4, 100, $5, true
|
||||||
WHERE NOT EXISTS (
|
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) {
|
for (const s of stops) {
|
||||||
await client.query(
|
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'
|
SELECT $1, $2, $3, $4::jsonb, 'active'
|
||||||
WHERE NOT EXISTS (
|
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 = [
|
const customers = [
|
||||||
{ name: "Green Grocer Co.", email: "buyer@greengrocer.example", phone: "555-010-0010" },
|
{ name: "Green Grocer Co.", email: "buyer@greengrocer.example", phone: "555-010-0010" },
|
||||||
{ name: "Sunset Cafe", email: "orders@sunsetcafe.example", phone: "555-010-0020" },
|
{ name: "Sunset Cafe", email: "orders@sunsetcafe.example", phone: "555-010-0020" },
|
||||||
@@ -241,73 +126,37 @@ async function main() {
|
|||||||
];
|
];
|
||||||
for (const c of customers) {
|
for (const c of customers) {
|
||||||
await client.query(
|
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
|
SELECT $1, $2, $3, $4, true, true
|
||||||
WHERE NOT EXISTS (
|
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 }>(
|
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', '<h1>Fresh this week</h1><p>Hi {{name}}, our harvest is in. Reply to reserve.</p>'
|
SELECT $1, 'Weekly Availability', 'This week at the grove', '<h1>Fresh this week</h1><p>Hi {{name}}, our harvest is in. Reply to reserve.</p>'
|
||||||
WHERE NOT EXISTS (
|
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`,
|
RETURNING id`,
|
||||||
[tenantId],
|
[brandId],
|
||||||
);
|
);
|
||||||
if (tmplRes.rows[0]) {
|
if (tmplRes.rows[0]) {
|
||||||
await client.query(
|
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'
|
SELECT $1, $2, 'Welcome series — week 1', 'draft'
|
||||||
WHERE NOT EXISTS (
|
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`);
|
console.log(`Seeded ${brandsData.length} brands 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)"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
await client.query("COMMIT");
|
||||||
console.log("✅ Seed complete");
|
console.log("✅ Seed complete");
|
||||||
|
|||||||
+47
-25
@@ -1,57 +1,79 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Dockerfile.nextjs — multi-stage build for the Next.js frontend
|
# 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:
|
# Production-ready Docker image for Next.js with:
|
||||||
# - `NEXT_PUBLIC_API_URL` must be present at BUILD time (Next.js inlines
|
# - Multi-stage build for minimal image size
|
||||||
# it into the client JS). We pass it through as an ARGs so the build
|
# - Standalone server output for containerized deployment
|
||||||
# context is reproducible (`docker build --build-arg` or via deploy.sh's
|
# - Non-root user for security
|
||||||
# `docker compose --env-file` flow).
|
# - Health check support
|
||||||
# - 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
|
# Build args (set via --build-arg or docker-compose args):
|
||||||
# keeps the image lean and avoids double-building.
|
# - 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
|
FROM node:20-alpine AS deps
|
||||||
WORKDIR /app
|
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
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy node_modules from deps stage
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# These ARGs are wired through docker-compose's `args:` block (or the CLI).
|
# Set build arguments (inlined into client bundle)
|
||||||
# deploy.sh exports them in the build environment.
|
ARG NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||||
ARG NEXT_PUBLIC_API_URL
|
|
||||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
ARG NEXTJS_HOST_PORT
|
ARG NODE_ENV=production
|
||||||
ENV NEXTJS_HOST_PORT=${NEXTJS_HOST_PORT}
|
ENV NODE_ENV=${NODE_ENV}
|
||||||
|
|
||||||
|
# Build the Next.js application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- runner: minimal image, standalone server -----------------------------
|
# ── Stage 3: Runtime ─────────────────────────────────────────────────────────
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set production environment
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
# Run as non-root.
|
# Create non-root user for security
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
USER nextjs
|
# Expose the port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Adjust this CMD to match the actual server file your build emits.
|
# Switch to non-root user
|
||||||
# For `output: "standalone"` in next.config.js the file is server.js.
|
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"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -2,19 +2,50 @@
|
|||||||
# docker-compose.yml — production stack consumed by deploy.sh
|
# docker-compose.yml — production stack consumed by deploy.sh
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
#
|
#
|
||||||
# Only `postgrest` lives in docker. Postgres itself runs on the host
|
# Complete Docker stack for production deployment:
|
||||||
# (the deploy workflow applies migrations via `psql -h 127.0.0.1`).
|
# - Next.js frontend (Node.js standalone server)
|
||||||
# Next.js runs under PM2 on the host — it is NOT a docker service.
|
# - PostgREST API (optional, for direct PostgreSQL access)
|
||||||
#
|
#
|
||||||
# The host-side port (POSTGREST_HOST_PORT) is written by the deploy
|
# Postgres itself runs on the host (the deploy workflow applies migrations
|
||||||
# workflow into $APP_DIR/.env. We interpolate from there with
|
# via `psql -h 127.0.0.1`). Next.js can also run under PM2 on the host —
|
||||||
# ${VAR:-3011} so a manual `docker compose up` without the deploy
|
# this compose file provides a Docker alternative for the frontend.
|
||||||
# script still works.
|
#
|
||||||
|
# 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
|
name: prod-app # default project name; deploy.sh overrides with -p
|
||||||
|
|
||||||
services:
|
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:
|
postgrest:
|
||||||
image: postgrest/postgrest:latest
|
image: postgrest/postgrest:latest
|
||||||
container_name: prod-app-postgrest
|
container_name: prod-app-postgrest
|
||||||
@@ -29,6 +60,9 @@ services:
|
|||||||
PGRST_SERVER_PORT: 3000
|
PGRST_SERVER_PORT: 3000
|
||||||
# Optional: tighten CORS for your real domain
|
# Optional: tighten CORS for your real domain
|
||||||
PGRST_DB_TXN_END: "commit-allow-overwrite"
|
PGRST_DB_TXN_END: "commit-allow-overwrite"
|
||||||
|
depends_on:
|
||||||
|
nextjs:
|
||||||
|
condition: service_healthy
|
||||||
# Healthcheck lets `docker compose ps` show healthy state.
|
# Healthcheck lets `docker compose ps` show healthy state.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ const eslintConfig = defineConfig([
|
|||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"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
|
// 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",
|
"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;
|
export default eslintConfig;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Enable standalone output for Docker/PM2 deployment
|
||||||
|
output: "standalone",
|
||||||
|
|
||||||
// Lock the file-tracing root to the project directory. Without this,
|
// Lock the file-tracing root to the project directory. Without this,
|
||||||
// Next.js 16 walks up from package.json looking for a lockfile, finds
|
// Next.js 16 walks up from package.json looking for a lockfile, finds
|
||||||
// the homelab runner's stale `act` cache at
|
// the homelab runner's stale `act` cache at
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
@@ -24,8 +24,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.96.0",
|
"@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",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
|
"@neondatabase/auth": "^0.4.2-beta",
|
||||||
"@sentry/nextjs": "^10.55.0",
|
"@sentry/nextjs": "^10.55.0",
|
||||||
"@stripe/react-stripe-js": "^6.6.0",
|
"@stripe/react-stripe-js": "^6.6.0",
|
||||||
"@stripe/stripe-js": "^9.7.0",
|
"@stripe/stripe-js": "^9.7.0",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 450 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
@@ -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();
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -1,48 +1,60 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import "server-only";
|
||||||
import { query } from "@/lib/db";
|
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.
|
* Change a user's password. Requires admin privileges.
|
||||||
*
|
*
|
||||||
* Reads the Auth.js v5 session to identify the user. The session's
|
* Use this for admin-initiated password resets. For self-service
|
||||||
* `user.id` is either:
|
* password changes, users should use the forgot password flow.
|
||||||
* - 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.
|
|
||||||
*/
|
*/
|
||||||
export async function updatePasswordAction(
|
export async function updatePasswordAction(
|
||||||
|
userId: string,
|
||||||
newPassword: string
|
newPassword: string
|
||||||
): Promise<{ error?: string; userId?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const session = await auth();
|
// Verify the caller is an admin
|
||||||
const uid = session?.user?.id;
|
const adminUser = await getAdminUser();
|
||||||
if (!uid) {
|
if (!adminUser) {
|
||||||
return { error: "Not authenticated. Please log in again." };
|
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 (adminUser.role !== "platform_admin") {
|
||||||
if (!UUID_REGEX.test(uid)) {
|
return { success: false, error: "Only platform admins can change passwords." };
|
||||||
return {
|
}
|
||||||
error:
|
|
||||||
"Password change is not available for social sign-in accounts. Please contact an admin.",
|
// Validate password
|
||||||
};
|
if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
return { success: false, error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The RPC is SECURITY DEFINER and returns a single row (or raises).
|
const result = await setUserPassword({
|
||||||
// We SELECT it (rather than SELECT update_user_password(...)) so the
|
userId,
|
||||||
// call stays a normal parameterized query and we can read the result.
|
newPassword,
|
||||||
await query("SELECT update_user_password($1, $2)", [uid, newPassword]);
|
});
|
||||||
return { userId: uid };
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
const message =
|
console.error("[updatePassword] Unexpected error:", err);
|
||||||
err instanceof Error ? err.message : "Failed to update password.";
|
return { success: false, error: "An unexpected error occurred." };
|
||||||
return { error: message };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current session user ID. Used by the change-password UI.
|
||||||
|
*/
|
||||||
|
export async function getCurrentUserId(): Promise<string | null> {
|
||||||
|
const { data: session } = await getSession();
|
||||||
|
return session?.user?.id ?? null;
|
||||||
|
}
|
||||||
|
|||||||
+17
-81
@@ -3,9 +3,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { pool, query } from "@/lib/db";
|
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 = {
|
export type AdminUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -112,16 +109,30 @@ async function sendWelcomeEmailSafe(input: {
|
|||||||
name: string;
|
name: string;
|
||||||
role: "platform_admin" | "brand_admin" | "store_employee";
|
role: "platform_admin" | "brand_admin" | "store_employee";
|
||||||
password: string;
|
password: string;
|
||||||
|
brandId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||||
|
const { getBrandSettings } = await import("@/actions/brand-settings");
|
||||||
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
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({
|
await sendWelcomeEmail({
|
||||||
to: input.to,
|
to: input.to,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||||
brandName: "Tuxedo Corn",
|
brandName,
|
||||||
tempPassword: input.password,
|
tempPassword: input.password,
|
||||||
|
logoUrl: logoUrl ?? undefined,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// welcome email is best-effort; never block user creation
|
// welcome email is best-effort; never block user creation
|
||||||
@@ -131,14 +142,6 @@ async function sendWelcomeEmailSafe(input: {
|
|||||||
// ─── Public actions ─────────────────────────────────────────────────────────
|
// ─── Public actions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
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 {
|
try {
|
||||||
const sql = brandId
|
const sql = brandId
|
||||||
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
? `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 }> {
|
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 {
|
try {
|
||||||
// No Supabase Auth — `user_id` stays NULL until the user signs in
|
// No Supabase Auth — `user_id` stays NULL until the user signs in
|
||||||
// via Auth.js and `get_admin_user_for_session` matches them by
|
// 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],
|
name: input.display_name ?? input.email.split("@")[0],
|
||||||
role: input.role,
|
role: input.role,
|
||||||
password: input.password,
|
password: input.password,
|
||||||
|
brandId: input.brand_id ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { user: mapUserRow(rows[0]), error: null };
|
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 }> {
|
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<string, unknown>)[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mockUsers[idx] = merged;
|
|
||||||
return { user: merged, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build a partial SET clause. Each `can_manage_*` column is set
|
// Build a partial SET clause. Each `can_manage_*` column is set
|
||||||
// individually — the input's `flags` partial is spread across them.
|
// 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 }> {
|
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 {
|
try {
|
||||||
// No Supabase Auth — nothing to delete from the auth service.
|
// No Supabase Auth — nothing to delete from the auth service.
|
||||||
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
|
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 }> {
|
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 {
|
try {
|
||||||
const { rowCount } = await query(
|
const { rowCount } = await query(
|
||||||
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
|
`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 }> {
|
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 {
|
try {
|
||||||
// The SaaS rebuild renamed `brands` → `tenants` (see db/schema/tenants.ts).
|
|
||||||
// Sort by name so the platform admin's brand picker stays stable.
|
// Sort by name so the platform admin's brand picker stays stable.
|
||||||
const { rows } = await query<{ id: string; name: string }>(
|
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 };
|
return { brands: rows, error: null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function saveAIPreferences(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
config: AIAuthConfig
|
config: AIAuthConfig
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const { error } = await supabase
|
const result = await supabase
|
||||||
.from("brand_ai_settings")
|
.from("brand_ai_settings")
|
||||||
.upsert({
|
.upsert({
|
||||||
brand_id: brandId,
|
brand_id: brandId,
|
||||||
@@ -43,9 +43,9 @@ export async function saveAIPreferences(
|
|||||||
model: config.model || "gpt-4o-mini",
|
model: config.model || "gpt-4o-mini",
|
||||||
max_tokens: config.max_tokens || 4000,
|
max_tokens: config.max_tokens || 4000,
|
||||||
updated_at: new Date().toISOString(),
|
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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export type ConversionFunnel = {
|
|||||||
// when the relevant data isn't present.
|
// when the relevant data isn't present.
|
||||||
//
|
//
|
||||||
// `recent_orders` / `conversion_funnel` use the new `orders` schema directly
|
// `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
|
* Aggregate KPIs over a date window. Replaces the
|
||||||
@@ -84,7 +84,7 @@ async function getReportsSummary(
|
|||||||
const params: unknown[] = [startDate, endDate];
|
const params: unknown[] = [startDate, endDate];
|
||||||
let brandFilter = "";
|
let brandFilter = "";
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
brandFilter = `AND tenant_id = $3::uuid`;
|
brandFilter = `AND brand_id = $3::uuid`;
|
||||||
params.push(brandId);
|
params.push(brandId);
|
||||||
}
|
}
|
||||||
const { rows } = await pool.query<{
|
const { rows } = await pool.query<{
|
||||||
@@ -137,7 +137,7 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
|
|||||||
const customerParams: unknown[] = [];
|
const customerParams: unknown[] = [];
|
||||||
let customerFilter = "";
|
let customerFilter = "";
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
customerFilter = "WHERE tenant_id = $1::uuid";
|
customerFilter = "WHERE brand_id = $1::uuid";
|
||||||
customerParams.push(brandId);
|
customerParams.push(brandId);
|
||||||
}
|
}
|
||||||
const customerCountRes = await pool.query<{ count: string }>(
|
const customerCountRes = await pool.query<{ count: string }>(
|
||||||
@@ -184,7 +184,7 @@ export async function getRevenueChart(periodDays: number = 30): Promise<RevenueD
|
|||||||
const params: unknown[] = [startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]];
|
const params: unknown[] = [startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]];
|
||||||
let brandFilter = "";
|
let brandFilter = "";
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
brandFilter = "AND tenant_id = $3::uuid";
|
brandFilter = "AND brand_id = $3::uuid";
|
||||||
params.push(brandId);
|
params.push(brandId);
|
||||||
}
|
}
|
||||||
const { rows } = await pool.query<{ date: string; revenue: number; orders: number }>(
|
const { rows } = await pool.query<{ date: string; revenue: number; orders: number }>(
|
||||||
@@ -219,7 +219,7 @@ export async function getTopProducts(limit: number = 5): Promise<ProductPerforma
|
|||||||
const params: unknown[] = [startDate, endDate];
|
const params: unknown[] = [startDate, endDate];
|
||||||
let brandFilter = "";
|
let brandFilter = "";
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
brandFilter = "AND o.tenant_id = $3::uuid";
|
brandFilter = "AND o.brand_id = $3::uuid";
|
||||||
params.push(brandId);
|
params.push(brandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
|||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
let brandFilter = "";
|
let brandFilter = "";
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
brandFilter = "WHERE tenant_id = $1::uuid";
|
brandFilter = "WHERE brand_id = $1::uuid";
|
||||||
params.push(brandId);
|
params.push(brandId);
|
||||||
}
|
}
|
||||||
const cappedLimit = Math.max(1, Math.min(limit, 100));
|
const cappedLimit = Math.max(1, Math.min(limit, 100));
|
||||||
@@ -322,7 +322,7 @@ export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
|||||||
const params: unknown[] = [startDate, endDate];
|
const params: unknown[] = [startDate, endDate];
|
||||||
let brandFilter = "";
|
let brandFilter = "";
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
brandFilter = "AND tenant_id = $3::uuid";
|
brandFilter = "AND brand_id = $3::uuid";
|
||||||
params.push(brandId);
|
params.push(brandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +365,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
|||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
let brandFilter = "";
|
let brandFilter = "";
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
brandFilter = "WHERE tenant_id = $1::uuid";
|
brandFilter = "WHERE brand_id = $1::uuid";
|
||||||
params.push(brandId);
|
params.push(brandId);
|
||||||
}
|
}
|
||||||
const { rows } = await pool.query<{ status: string }>(
|
const { rows } = await pool.query<{ status: string }>(
|
||||||
|
|||||||
@@ -1,44 +1,14 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import "server-only";
|
import "server-only";
|
||||||
import { signIn, signOut } from "@/lib/auth";
|
import { signOut } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kick off the Google OAuth flow. Auth.js will redirect to Google's
|
* Sign out and clear the Neon Auth session cookie.
|
||||||
* consent screen and then back to /api/auth/callback/google, which sets
|
|
||||||
* the session cookie and redirects to /admin.
|
|
||||||
*/
|
|
||||||
export async function signInWithGoogle(): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
export async function signOutAction(): Promise<void> {
|
export async function signOutAction(): Promise<void> {
|
||||||
await signOut({ redirectTo: "/login" });
|
console.log("[auth/sign-out] Signing out");
|
||||||
|
await signOut();
|
||||||
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
|
||||||
* <form action={signInWithGoogle}>
|
|
||||||
* <button type="submit">Sign in with Google</button>
|
|
||||||
* </form>
|
|
||||||
*
|
|
||||||
* 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<void> {
|
|
||||||
await signIn("google", { redirectTo: "/admin" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signOutAction(): Promise<void> {
|
|
||||||
await signOut({ redirectTo: "/login" });
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { products } from "@/db/schema";
|
import { products } from "@/db/schema";
|
||||||
import { and, eq, count } from "drizzle-orm";
|
import { and, eq, count } from "drizzle-orm";
|
||||||
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
|
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_stops_monthly, 10) AS max_stops_monthly,
|
||||||
COALESCE(t.max_products, 25) AS max_products,
|
COALESCE(t.max_products, 25) AS max_products,
|
||||||
jsonb_build_object(
|
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
|
'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())),
|
AND s.created_at >= date_trunc('month', now())),
|
||||||
'products', (SELECT count(*)::int FROM products p
|
'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
|
) AS usage
|
||||||
FROM tenants t
|
FROM brands t
|
||||||
WHERE t.id = $1`,
|
WHERE t.id = $1`,
|
||||||
[brandId]
|
[brandId]
|
||||||
);
|
);
|
||||||
@@ -136,14 +136,14 @@ export async function getBillingOverview(
|
|||||||
}>(
|
}>(
|
||||||
`SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id,
|
`SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id,
|
||||||
NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end
|
NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end
|
||||||
FROM tenants WHERE id = $1`,
|
FROM brands WHERE id = $1`,
|
||||||
[brandId]
|
[brandId]
|
||||||
);
|
);
|
||||||
const brand = brandRes.rows[0] ?? null;
|
const brand = brandRes.rows[0] ?? null;
|
||||||
|
|
||||||
// 3) Enabled add-ons (feature flags from brand_settings)
|
// 3) Enabled add-ons (feature flags from brand_settings)
|
||||||
const addonsRes = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
|
const addonsRes = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
|
||||||
`SELECT feature_flags FROM brand_settings WHERE tenant_id = $1`,
|
`SELECT feature_flags FROM brand_settings WHERE brand_id = $1`,
|
||||||
[brandId]
|
[brandId]
|
||||||
);
|
);
|
||||||
const flags = addonsRes.rows[0]?.feature_flags ?? {};
|
const flags = addonsRes.rows[0]?.feature_flags ?? {};
|
||||||
@@ -155,13 +155,13 @@ export async function getBillingOverview(
|
|||||||
// 4) Active product count — same semantics as the dashboard
|
// 4) Active product count — same semantics as the dashboard
|
||||||
// (active=true). Keeps the billing page in sync with /admin
|
// (active=true). Keeps the billing page in sync with /admin
|
||||||
// "Active Products" stat.
|
// "Active Products" stat.
|
||||||
const productCountRows = await withTenant(brandId, (db) =>
|
const productCountRows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({ value: count() })
|
.select({ value: count() })
|
||||||
.from(products)
|
.from(products)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(products.tenantId, brandId),
|
eq(products.brandId, brandId),
|
||||||
eq(products.active, true)
|
eq(products.active, true)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ type LineItem = {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Stripe API version type - using const assertion for type safety
|
||||||
|
type StripeApiVersion = "2026-05-27.dahlia";
|
||||||
|
|
||||||
export async function createRetailStripeCheckoutSession(
|
export async function createRetailStripeCheckoutSession(
|
||||||
items: LineItem[],
|
items: LineItem[],
|
||||||
orderId: string,
|
orderId: string,
|
||||||
@@ -20,7 +23,7 @@ export async function createRetailStripeCheckoutSession(
|
|||||||
if (!stripeKey) return { success: false, error: "Stripe not configured on this server." };
|
if (!stripeKey) return { success: false, error: "Stripe not configured on this server." };
|
||||||
|
|
||||||
const Stripe = (await import("stripe")).default;
|
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) => ({
|
const lineItems = items.map((item) => ({
|
||||||
price_data: {
|
price_data: {
|
||||||
@@ -35,7 +38,7 @@ export async function createRetailStripeCheckoutSession(
|
|||||||
let brandName = "Route Commerce";
|
let brandName = "Route Commerce";
|
||||||
try {
|
try {
|
||||||
const brandRes = await pool.query<{ name: string }>(
|
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]
|
[brandId]
|
||||||
);
|
);
|
||||||
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
|
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { pool } from "@/lib/db";
|
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
|
* Creates a Stripe PaymentIntent for the supplied cart so the browser
|
||||||
* can confirm the payment with embedded Stripe Elements (Apple Pay /
|
* 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 = (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 —
|
// Compute the subtotal in cents. We don't compute sales tax here —
|
||||||
// Stripe's `automatic_tax` would be ideal but requires address collection
|
// Stripe's `automatic_tax` would be ideal but requires address collection
|
||||||
@@ -67,7 +70,7 @@ export async function createRetailPaymentIntent(
|
|||||||
if (brandId) {
|
if (brandId) {
|
||||||
try {
|
try {
|
||||||
const brandRes = await pool.query<{ name: string }>(
|
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]
|
[brandId]
|
||||||
);
|
);
|
||||||
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
|
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
|
// Stripe API version type - using const assertion for type safety
|
||||||
|
type StripeApiVersion = "2026-05-27.dahlia";
|
||||||
|
|
||||||
// ── Price ID config ────────────────────────────────────────────────────────────
|
// ── Price ID config ────────────────────────────────────────────────────────────
|
||||||
// Maps plan/addon keys to Stripe price IDs via environment variables
|
// 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
|
// Get brand's Stripe customer ID
|
||||||
const custRes = await pool.query<{ stripe_customer_id: string | null; name: string }>(
|
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]
|
[brandId]
|
||||||
);
|
);
|
||||||
const brand = custRes.rows[0];
|
const brand = custRes.rows[0];
|
||||||
@@ -54,7 +57,7 @@ export async function createStripeCheckoutSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Stripe = (await import("stripe")).default;
|
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";
|
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
|
// Get active subscription for this brand
|
||||||
const subRes = await pool.query<{ stripe_subscription_id: string | null }>(
|
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]
|
[brandId]
|
||||||
);
|
);
|
||||||
if (subRes.rows.length === 0) {
|
if (subRes.rows.length === 0) {
|
||||||
@@ -133,7 +136,7 @@ export async function cancelAddonSubscription(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Stripe = (await import("stripe")).default;
|
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
|
// Retrieve subscription and find the item for this add-on
|
||||||
const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id);
|
const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id);
|
||||||
|
|||||||
@@ -3,6 +3,29 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
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 }> {
|
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
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
|
// Get stripe_customer_id from tenants table
|
||||||
const custRes = await pool.query<{ stripe_customer_id: string | null }>(
|
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]
|
[brandId]
|
||||||
);
|
);
|
||||||
const stripeCustomerId = custRes.rows[0]?.stripe_customer_id;
|
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 = (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({
|
const session = await stripe.billingPortal.sessions.create({
|
||||||
customer: stripeCustomerId,
|
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
|
// Replicate get_brand_plan_info via a JOIN on tenants + plans
|
||||||
const res = await pool.query<{
|
const res = await pool.query<{
|
||||||
plan_tier: string;
|
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_stops_monthly, 10) AS max_stops_monthly,
|
||||||
COALESCE(t.max_products, 25) AS max_products,
|
COALESCE(t.max_products, 25) AS max_products,
|
||||||
jsonb_build_object(
|
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
|
'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())),
|
AND s.created_at >= date_trunc('month', now())),
|
||||||
'products', (SELECT count(*)::int FROM products p
|
'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
|
) AS usage
|
||||||
FROM tenants t
|
FROM brands t
|
||||||
WHERE t.id = $1`,
|
WHERE t.id = $1`,
|
||||||
[brandId]
|
[brandId]
|
||||||
);
|
);
|
||||||
@@ -111,7 +134,7 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool
|
|||||||
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
|
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
|
||||||
// get_brand_features returns JSONB — a single object, not an array
|
// get_brand_features returns JSONB — a single object, not an array
|
||||||
const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
|
const res = await pool.query<{ feature_flags: Record<string, unknown> | 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]
|
[brandId]
|
||||||
);
|
);
|
||||||
const flags = res.rows[0]?.feature_flags ?? {};
|
const flags = res.rows[0]?.feature_flags ?? {};
|
||||||
@@ -123,7 +146,7 @@ export async function getEnabledAddons(brandId: string): Promise<Record<string,
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
|
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<WholesaleOrder[]> {
|
||||||
try {
|
try {
|
||||||
const res = await pool.query(
|
const res = await pool.query(
|
||||||
"SELECT * FROM get_wholesale_orders($1)",
|
"SELECT * FROM get_wholesale_orders($1)",
|
||||||
|
|||||||
@@ -2,21 +2,16 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
import { uploadObject, BUCKETS } from "@/lib/storage";
|
||||||
|
|
||||||
export type UploadLogoResult =
|
export type UploadLogoResult =
|
||||||
| { success: true; logoUrl: string }
|
| { success: true; logoUrl: string }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a brand logo (light or dark variant) to Supabase Storage and
|
* Upload a brand logo (light or dark variant) to MinIO and save the
|
||||||
* save the resulting public URL to the brand_settings row via the
|
* resulting public URL to the brand_settings row via the
|
||||||
* `upsert_brand_settings` SECURITY DEFINER RPC.
|
* `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(
|
export async function uploadBrandLogo(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
@@ -42,37 +37,28 @@ export async function uploadBrandLogo(
|
|||||||
|
|
||||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||||
const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
|
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();
|
try {
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const logoUrl = await uploadObject({
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
bucket: BUCKETS.BRAND_LOGOS,
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
key: storageKey,
|
||||||
|
|
||||||
const uploadRes = await fetch(
|
|
||||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
|
|
||||||
body: buffer,
|
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: true, logoUrl };
|
||||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
} 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(
|
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 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();
|
try {
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const logoUrl = await uploadObject({
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
bucket: BUCKETS.BRAND_LOGOS,
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
key: storageKey,
|
||||||
|
|
||||||
const uploadRes = await fetch(
|
|
||||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
|
|
||||||
body: buffer,
|
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: true, logoUrl };
|
||||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
} 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(
|
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 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();
|
try {
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const logoUrl = await uploadObject({
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
bucket: BUCKETS.BRAND_LOGOS,
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
key: storageKey,
|
||||||
|
|
||||||
const uploadRes = await fetch(
|
|
||||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
|
|
||||||
body: buffer,
|
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: true, logoUrl };
|
||||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
} 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -110,8 +110,24 @@ export async function createOrder(
|
|||||||
// Send order receipt email
|
// Send order receipt email
|
||||||
try {
|
try {
|
||||||
const { sendOrderReceiptEmail } = await import("@/lib/email-service");
|
const { sendOrderReceiptEmail } = await import("@/lib/email-service");
|
||||||
|
const { getBrandSettingsPublic } = await import("@/actions/brand-settings");
|
||||||
const rawItems = data.items ?? [];
|
const rawItems = data.items ?? [];
|
||||||
const taxAmount = (data as { tax_amount?: number }).tax_amount ?? 0;
|
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({
|
await sendOrderReceiptEmail({
|
||||||
customerName,
|
customerName,
|
||||||
customerEmail,
|
customerEmail,
|
||||||
@@ -131,6 +147,7 @@ export async function createOrder(
|
|||||||
stopTime: data.stop_time ?? undefined,
|
stopTime: data.stop_time ?? undefined,
|
||||||
stopLocation: data.stop_location ?? undefined,
|
stopLocation: data.stop_location ?? undefined,
|
||||||
brandName: "Tuxedo Corn",
|
brandName: "Tuxedo Corn",
|
||||||
|
logoUrl,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Email failure should not fail the order
|
// Email failure should not fail the order
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { and, desc, eq, SQL } from "drizzle-orm";
|
import { and, desc, eq, SQL } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import { campaigns, emailTemplates } from "@/db/schema";
|
import { campaigns, emailTemplates } from "@/db/schema";
|
||||||
|
|
||||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||||
@@ -83,7 +83,7 @@ function stripHtml(html: string | null): string {
|
|||||||
function rowToCampaign(c: CampaignRow, t?: TemplateRow | null): Campaign {
|
function rowToCampaign(c: CampaignRow, t?: TemplateRow | null): Campaign {
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
brand_id: c.tenantId,
|
brand_id: c.brandId,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
subject: t?.subject ?? null,
|
subject: t?.subject ?? null,
|
||||||
body_text: stripHtml(t?.bodyHtml ?? null),
|
body_text: stripHtml(t?.bodyHtml ?? null),
|
||||||
@@ -111,7 +111,7 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = activeBrandId
|
const rows = activeBrandId
|
||||||
? await withTenant(activeBrandId, (db) =>
|
? await withBrand(activeBrandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
campaign: campaigns,
|
campaign: campaigns,
|
||||||
@@ -178,7 +178,7 @@ export async function upsertCampaign(params: {
|
|||||||
const subject = params.subject ?? "";
|
const subject = params.subject ?? "";
|
||||||
const bodyHtml = params.body_html ?? (params.body_text ? `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>` : "<p></p>");
|
const bodyHtml = params.body_html ?? (params.body_text ? `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>` : "<p></p>");
|
||||||
|
|
||||||
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.
|
// Resolve / create the linked email_templates row.
|
||||||
let templateId: string | null = params.template_id ?? null;
|
let templateId: string | null = params.template_id ?? null;
|
||||||
let templateRow: TemplateRow | null = null;
|
let templateRow: TemplateRow | null = null;
|
||||||
@@ -187,7 +187,7 @@ export async function upsertCampaign(params: {
|
|||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(emailTemplates)
|
.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);
|
.limit(1);
|
||||||
if (existing[0]) {
|
if (existing[0]) {
|
||||||
const updated = await db
|
const updated = await db
|
||||||
@@ -207,7 +207,7 @@ export async function upsertCampaign(params: {
|
|||||||
const inserted = await db
|
const inserted = await db
|
||||||
.insert(emailTemplates)
|
.insert(emailTemplates)
|
||||||
.values({
|
.values({
|
||||||
tenantId: params.brand_id,
|
brandId: params.brand_id,
|
||||||
name: params.name,
|
name: params.name,
|
||||||
subject,
|
subject,
|
||||||
bodyHtml,
|
bodyHtml,
|
||||||
@@ -229,7 +229,7 @@ export async function upsertCampaign(params: {
|
|||||||
scheduledFor: scheduled,
|
scheduledFor: scheduled,
|
||||||
updatedAt: new Date(),
|
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();
|
.returning();
|
||||||
if (!updated[0]) {
|
if (!updated[0]) {
|
||||||
return { row: null, template: null };
|
return { row: null, template: null };
|
||||||
@@ -239,7 +239,7 @@ export async function upsertCampaign(params: {
|
|||||||
const inserted = await db
|
const inserted = await db
|
||||||
.insert(campaigns)
|
.insert(campaigns)
|
||||||
.values({
|
.values({
|
||||||
tenantId: params.brand_id,
|
brandId: params.brand_id,
|
||||||
name: params.name,
|
name: params.name,
|
||||||
templateId,
|
templateId,
|
||||||
status,
|
status,
|
||||||
@@ -274,8 +274,8 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (activeBrandId) {
|
if (activeBrandId) {
|
||||||
await withTenant(activeBrandId, (db) =>
|
await withBrand(activeBrandId, (db) =>
|
||||||
db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId))),
|
db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.brandId, activeBrandId))),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await withPlatformAdmin((db) => db.delete(campaigns).where(eq(campaigns.id, campaignId)));
|
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 {
|
try {
|
||||||
const result = activeBrandId
|
const result = activeBrandId
|
||||||
? await withTenant(activeBrandId, (db) =>
|
? await withBrand(activeBrandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({ campaign: campaigns, template: emailTemplates })
|
.select({ campaign: campaigns, template: emailTemplates })
|
||||||
.from(campaigns)
|
.from(campaigns)
|
||||||
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
|
.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)
|
.limit(1)
|
||||||
.then((r) => r[0] ?? null),
|
.then((r) => r[0] ?? null),
|
||||||
)
|
)
|
||||||
@@ -318,7 +318,7 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
|
|||||||
|
|
||||||
if (!result) return null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { and, eq, ilike, or, sql, SQL } from "drizzle-orm";
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { parseCSVWithLimits } from "@/lib/csv-parser";
|
import { parseCSVWithLimits } from "@/lib/csv-parser";
|
||||||
import { buildImportPreview } from "@/lib/column-detector";
|
import { buildImportPreview } from "@/lib/column-detector";
|
||||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import { customers } from "@/db/schema";
|
import { customers } from "@/db/schema";
|
||||||
|
|
||||||
export type ContactSource = "order" | "import" | "manual" | "admin";
|
export type ContactSource = "order" | "import" | "manual" | "admin";
|
||||||
@@ -19,7 +19,7 @@ export type ContactSource = "order" | "import" | "manual" | "admin";
|
|||||||
*/
|
*/
|
||||||
export type Contact = {
|
export type Contact = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string;
|
brand_id: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
@@ -101,23 +101,9 @@ export type GetContactsResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function rowToContact(row: typeof customers.$inferSelect): Contact {
|
function rowToContact(row: typeof customers.$inferSelect): Contact {
|
||||||
// Derive first/last name from the stored single `name` column.
|
const firstName = row.firstName;
|
||||||
// Multi-word names split on the first whitespace; single-word names
|
const lastName = row.lastName;
|
||||||
// populate first_name and leave last_name null.
|
const fullName = [firstName, lastName].filter(Boolean).join(" ") || "";
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive unsubscribed_at: the new schema only has opt-in flags, not
|
// Derive unsubscribed_at: the new schema only has opt-in flags, not
|
||||||
// a timestamp. Mark the unsubscribe moment as updatedAt if opted out
|
// a timestamp. Mark the unsubscribe moment as updatedAt if opted out
|
||||||
@@ -126,10 +112,10 @@ function rowToContact(row: typeof customers.$inferSelect): Contact {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
tenant_id: row.tenantId,
|
brand_id: row.brandId,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
full_name: row.name,
|
full_name: row.fullName ?? "",
|
||||||
first_name: firstName,
|
first_name: firstName,
|
||||||
last_name: lastName,
|
last_name: lastName,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
@@ -162,13 +148,13 @@ export async function getContacts(params: {
|
|||||||
const search = params.search?.trim() ?? "";
|
const search = params.search?.trim() ?? "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
|
const conds: SQL[] = [eq(customers.brandId, params.brandId)];
|
||||||
if (search) {
|
if (search) {
|
||||||
const like = `%${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([
|
const [items, countRows] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
@@ -233,7 +219,7 @@ export async function upsertContact(contact: {
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const name =
|
const fullName =
|
||||||
contact.full_name?.trim() ||
|
contact.full_name?.trim() ||
|
||||||
[contact.first_name, contact.last_name].filter(Boolean).join(" ").trim() ||
|
[contact.first_name, contact.last_name].filter(Boolean).join(" ").trim() ||
|
||||||
contact.email ||
|
contact.email ||
|
||||||
@@ -243,18 +229,20 @@ export async function upsertContact(contact: {
|
|||||||
try {
|
try {
|
||||||
if (contact.id) {
|
if (contact.id) {
|
||||||
const contactId = contact.id;
|
const contactId = contact.id;
|
||||||
const updated = await withTenant(contact.brand_id, (db) =>
|
const updated = await withBrand(contact.brand_id, (db) =>
|
||||||
db
|
db
|
||||||
.update(customers)
|
.update(customers)
|
||||||
.set({
|
.set({
|
||||||
name,
|
fullName,
|
||||||
email: contact.email ?? null,
|
email: contact.email ?? null,
|
||||||
phone: contact.phone ?? null,
|
phone: contact.phone ?? null,
|
||||||
|
firstName: contact.first_name ?? null,
|
||||||
|
lastName: contact.last_name ?? null,
|
||||||
smsOptIn: contact.sms_opt_in ?? false,
|
smsOptIn: contact.sms_opt_in ?? false,
|
||||||
emailOptIn: contact.email_opt_in ?? true,
|
emailOptIn: contact.email_opt_in ?? true,
|
||||||
updatedAt: new Date(),
|
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(),
|
.returning(),
|
||||||
);
|
);
|
||||||
const row = updated[0];
|
const row = updated[0];
|
||||||
@@ -262,23 +250,25 @@ export async function upsertContact(contact: {
|
|||||||
return { success: true, contact: rowToContact(row) };
|
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;
|
const contactEmail = contact.email;
|
||||||
if (contactEmail) {
|
if (contactEmail) {
|
||||||
const existing = await withTenant(contact.brand_id, (db) =>
|
const existing = await withBrand(contact.brand_id, (db) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(customers)
|
.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),
|
.limit(1),
|
||||||
);
|
);
|
||||||
if (existing[0]) {
|
if (existing[0]) {
|
||||||
const updated = await withTenant(contact.brand_id, (db) =>
|
const updated = await withBrand(contact.brand_id, (db) =>
|
||||||
db
|
db
|
||||||
.update(customers)
|
.update(customers)
|
||||||
.set({
|
.set({
|
||||||
name,
|
fullName,
|
||||||
phone: contact.phone ?? null,
|
phone: contact.phone ?? null,
|
||||||
|
firstName: contact.first_name ?? null,
|
||||||
|
lastName: contact.last_name ?? null,
|
||||||
smsOptIn: contact.sms_opt_in ?? existing[0].smsOptIn,
|
smsOptIn: contact.sms_opt_in ?? existing[0].smsOptIn,
|
||||||
emailOptIn: contact.email_opt_in ?? existing[0].emailOptIn,
|
emailOptIn: contact.email_opt_in ?? existing[0].emailOptIn,
|
||||||
updatedAt: new Date(),
|
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
|
db
|
||||||
.insert(customers)
|
.insert(customers)
|
||||||
.values({
|
.values({
|
||||||
tenantId: contact.brand_id,
|
brandId: contact.brand_id,
|
||||||
name,
|
fullName,
|
||||||
email: contact.email ?? null,
|
email: contact.email ?? null,
|
||||||
phone: contact.phone ?? null,
|
phone: contact.phone ?? null,
|
||||||
|
firstName: contact.first_name ?? null,
|
||||||
|
lastName: contact.last_name ?? null,
|
||||||
smsOptIn: contact.sms_opt_in ?? false,
|
smsOptIn: contact.sms_opt_in ?? false,
|
||||||
emailOptIn: contact.email_opt_in ?? true,
|
emailOptIn: contact.email_opt_in ?? true,
|
||||||
})
|
})
|
||||||
@@ -328,7 +320,7 @@ export type ImportContactsResult = {
|
|||||||
* The legacy `import_communication_contacts_batch` RPC is replaced with
|
* The legacy `import_communication_contacts_batch` RPC is replaced with
|
||||||
* an in-process batch: parse → dedupe → upsert per row, returning the
|
* an in-process batch: parse → dedupe → upsert per row, returning the
|
||||||
* same ImportResult shape. This avoids a round-trip to the DB for each
|
* 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: {
|
export async function importContactsBatch(params: {
|
||||||
brandId: string;
|
brandId: string;
|
||||||
@@ -403,14 +395,14 @@ export async function optOutContact(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withTenant(params.brandId, (db) =>
|
await withBrand(params.brandId, (db) =>
|
||||||
db
|
db
|
||||||
.update(customers)
|
.update(customers)
|
||||||
.set({
|
.set({
|
||||||
...(params.method === "email" ? { emailOptIn: false } : { smsOptIn: false }),
|
...(params.method === "email" ? { emailOptIn: false } : { smsOptIn: false }),
|
||||||
updatedAt: new Date(),
|
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 };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -434,10 +426,10 @@ export async function deleteContact(id: string, brandId?: string): Promise<{ suc
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
await withTenant(brandId, (db) =>
|
await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.delete(customers)
|
.delete(customers)
|
||||||
.where(and(eq(customers.id, id), eq(customers.tenantId, brandId))),
|
.where(and(eq(customers.id, id), eq(customers.brandId, brandId))),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// platform_admin fallback — by id only
|
// platform_admin fallback — by id only
|
||||||
@@ -492,20 +484,20 @@ export async function exportContacts(params: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const rows = await withTenant(effectiveBrandId, (db) =>
|
const rows = await withBrand(effectiveBrandId, (db) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(
|
.where(
|
||||||
params.search
|
params.search
|
||||||
? and(
|
? and(
|
||||||
eq(customers.tenantId, effectiveBrandId),
|
eq(customers.brandId, effectiveBrandId),
|
||||||
or(
|
or(
|
||||||
ilike(customers.name, `%${params.search}%`),
|
ilike(customers.firstName, `%${params.search}%`),
|
||||||
ilike(customers.email, `%${params.search}%`),
|
ilike(customers.email, `%${params.search}%`),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: eq(customers.tenantId, effectiveBrandId),
|
: eq(customers.brandId, effectiveBrandId),
|
||||||
)
|
)
|
||||||
.limit(batchSize)
|
.limit(batchSize)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import { files } from "@/db/schema";
|
import { files } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
importContactsBatch,
|
importContactsBatch,
|
||||||
@@ -45,11 +45,11 @@ export async function uploadContactsToBucket(
|
|||||||
const storageKey = `imports/${brandId}/${timestamp}-${safeName}`;
|
const storageKey = `imports/${brandId}/${timestamp}-${safeName}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [row] = await withTenant(brandId, (db) =>
|
const [row] = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.insert(files)
|
.insert(files)
|
||||||
.values({
|
.values({
|
||||||
tenantId: brandId,
|
brandId: brandId,
|
||||||
storageKey,
|
storageKey,
|
||||||
mimeType: "text/csv",
|
mimeType: "text/csv",
|
||||||
sizeBytes: file.size,
|
sizeBytes: file.size,
|
||||||
@@ -130,7 +130,7 @@ export async function listImportHistory(
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await withTenant(brandId, (db) =>
|
const rows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
storageKey: files.storageKey,
|
storageKey: files.storageKey,
|
||||||
@@ -138,7 +138,7 @@ export async function listImportHistory(
|
|||||||
createdAt: files.createdAt,
|
createdAt: files.createdAt,
|
||||||
})
|
})
|
||||||
.from(files)
|
.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))
|
.orderBy(desc(files.createdAt))
|
||||||
.limit(limit),
|
.limit(limit),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { brandSettings } from "@/db/schema";
|
import { brandSettings } from "@/db/schema";
|
||||||
import type { AudienceRules } from "./campaigns";
|
import type { AudienceRules } from "./campaigns";
|
||||||
|
|
||||||
@@ -56,22 +56,22 @@ function isSegment(v: unknown): v is Segment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSegments(brandId: string): Promise<Segment[]> {
|
async function loadSegments(brandId: string): Promise<Segment[]> {
|
||||||
const rows = await withTenant(brandId, (db) =>
|
const rows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({ flags: brandSettings.featureFlags })
|
.select({ flags: brandSettings.featureFlags })
|
||||||
.from(brandSettings)
|
.from(brandSettings)
|
||||||
.where(eq(brandSettings.tenantId, brandId))
|
.where(eq(brandSettings.brandId, brandId))
|
||||||
.limit(1),
|
.limit(1),
|
||||||
);
|
);
|
||||||
return readSegments((rows[0]?.flags ?? null) as Record<string, unknown> | null);
|
return readSegments((rows[0]?.flags ?? null) as Record<string, unknown> | null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
|
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
|
||||||
await withTenant(brandId, async (db) => {
|
await withBrand(brandId, async (db) => {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select({ flags: brandSettings.featureFlags })
|
.select({ flags: brandSettings.featureFlags })
|
||||||
.from(brandSettings)
|
.from(brandSettings)
|
||||||
.where(eq(brandSettings.tenantId, brandId))
|
.where(eq(brandSettings.brandId, brandId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
||||||
const nextFlags: Record<string, unknown> = {
|
const nextFlags: Record<string, unknown> = {
|
||||||
@@ -81,7 +81,7 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise<void>
|
|||||||
await db
|
await db
|
||||||
.update(brandSettings)
|
.update(brandSettings)
|
||||||
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
||||||
.where(eq(brandSettings.tenantId, brandId));
|
.where(eq(brandSettings.brandId, brandId));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
import { and, eq, sql, SQL } from "drizzle-orm";
|
import { and, eq, sql, SQL } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
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 { campaigns, customers } from "@/db/schema";
|
||||||
import type { AudienceRules } from "./campaigns";
|
import type { AudienceRules } from "./campaigns";
|
||||||
|
|
||||||
export type AudiencePreviewResult = {
|
export type AudiencePreviewResult = {
|
||||||
count: number;
|
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 {
|
try {
|
||||||
const rows = await withTenant(brandId, (db) =>
|
const rows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: customers.id,
|
id: customers.id,
|
||||||
email: customers.email,
|
email: customers.email,
|
||||||
name: customers.name,
|
fullName: customers.fullName,
|
||||||
})
|
})
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)))
|
.where(and(eq(customers.brandId, brandId), eq(customers.emailOptIn, true)))
|
||||||
.limit(5),
|
.limit(5),
|
||||||
);
|
);
|
||||||
|
|
||||||
const countRows = await withTenant(brandId, (db) =>
|
const countRows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({ value: sql<number>`count(*)::int` })
|
.select({ value: sql<number>`count(*)::int` })
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true))),
|
.where(and(eq(customers.brandId, brandId), eq(customers.emailOptIn, true))),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -60,7 +60,7 @@ export async function previewCampaignAudience(
|
|||||||
sample_customers: rows.map((r) => ({
|
sample_customers: rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
email: r.email ?? "",
|
email: r.email ?? "",
|
||||||
name: r.name,
|
fullName: r.fullName,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -153,11 +153,11 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
const conds: SQL[] = [eq(campaigns.id, campaignId)];
|
const conds: SQL[] = [eq(campaigns.id, campaignId)];
|
||||||
if (activeBrandId) conds.push(eq(campaigns.tenantId, activeBrandId));
|
if (activeBrandId) conds.push(eq(campaigns.brandId, activeBrandId));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = activeBrandId
|
const updated = activeBrandId
|
||||||
? await withTenant(activeBrandId, (db) =>
|
? await withBrand(activeBrandId, (db) =>
|
||||||
db
|
db
|
||||||
.update(campaigns)
|
.update(campaigns)
|
||||||
.set({ status: "sent", sentAt: new Date() })
|
.set({ status: "sent", sentAt: new Date() })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { brandSettings } from "@/db/schema";
|
import { brandSettings } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
@@ -47,15 +47,15 @@ function readFlag(
|
|||||||
|
|
||||||
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
|
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
|
||||||
try {
|
try {
|
||||||
const rows = await withTenant(brandId, (db) =>
|
const rows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
tenantId: brandSettings.tenantId,
|
brandId: brandSettings.brandId,
|
||||||
featureFlags: brandSettings.featureFlags,
|
featureFlags: brandSettings.featureFlags,
|
||||||
updatedAt: brandSettings.updatedAt,
|
updatedAt: brandSettings.updatedAt,
|
||||||
})
|
})
|
||||||
.from(brandSettings)
|
.from(brandSettings)
|
||||||
.where(eq(brandSettings.tenantId, brandId))
|
.where(eq(brandSettings.brandId, brandId))
|
||||||
.limit(1),
|
.limit(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ export async function getCommunicationSettings(brandId: string): Promise<Communi
|
|||||||
const flags = (row.featureFlags ?? {}) as Record<string, unknown>;
|
const flags = (row.featureFlags ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.tenantId,
|
id: row.brandId,
|
||||||
brand_id: row.tenantId,
|
brand_id: row.brandId,
|
||||||
default_sender_email: readFlag(flags, "comm_default_sender_email"),
|
default_sender_email: readFlag(flags, "comm_default_sender_email"),
|
||||||
default_sender_name: readFlag(flags, "comm_default_sender_name"),
|
default_sender_name: readFlag(flags, "comm_default_sender_name"),
|
||||||
reply_to_email: readFlag(flags, "comm_reply_to_email"),
|
reply_to_email: readFlag(flags, "comm_reply_to_email"),
|
||||||
@@ -96,11 +96,11 @@ export async function upsertCommunicationSettings(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await withTenant(params.brand_id, async (db) => {
|
const updated = await withBrand(params.brand_id, async (db) => {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select({ flags: brandSettings.featureFlags })
|
.select({ flags: brandSettings.featureFlags })
|
||||||
.from(brandSettings)
|
.from(brandSettings)
|
||||||
.where(eq(brandSettings.tenantId, params.brand_id))
|
.where(eq(brandSettings.brandId, params.brand_id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
||||||
const nextFlags: Record<string, unknown> = {
|
const nextFlags: Record<string, unknown> = {
|
||||||
@@ -115,9 +115,9 @@ export async function upsertCommunicationSettings(params: {
|
|||||||
const result = await db
|
const result = await db
|
||||||
.update(brandSettings)
|
.update(brandSettings)
|
||||||
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
||||||
.where(eq(brandSettings.tenantId, params.brand_id))
|
.where(eq(brandSettings.brandId, params.brand_id))
|
||||||
.returning({
|
.returning({
|
||||||
tenantId: brandSettings.tenantId,
|
brandId: brandSettings.brandId,
|
||||||
updatedAt: brandSettings.updatedAt,
|
updatedAt: brandSettings.updatedAt,
|
||||||
});
|
});
|
||||||
return result[0] ?? null;
|
return result[0] ?? null;
|
||||||
@@ -128,8 +128,8 @@ export async function upsertCommunicationSettings(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settings: CommunicationSettings = {
|
const settings: CommunicationSettings = {
|
||||||
id: updated.tenantId,
|
id: updated.brandId,
|
||||||
brand_id: updated.tenantId,
|
brand_id: updated.brandId,
|
||||||
default_sender_email: params.sender_email ?? null,
|
default_sender_email: params.sender_email ?? null,
|
||||||
default_sender_name: params.sender_name ?? null,
|
default_sender_name: params.sender_name ?? null,
|
||||||
reply_to_email: params.reply_to_email ?? null,
|
reply_to_email: params.reply_to_email ?? null,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { and, eq, sql, SQL } from "drizzle-orm";
|
import { and, eq, sql, SQL } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { customers, orders, campaigns, emailTemplates } from "@/db/schema";
|
import { customers, orders, campaigns, emailTemplates } from "@/db/schema";
|
||||||
|
|
||||||
export type StopBlastResult =
|
export type StopBlastResult =
|
||||||
@@ -37,8 +37,8 @@ export async function sendStopBlast(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
|
const conds: SQL[] = [eq(customers.brandId, params.brandId)];
|
||||||
const recipientRows = await withTenant(params.brandId, async (db) => {
|
const recipientRows = await withBrand(params.brandId, async (db) => {
|
||||||
// Distinct customers from the tenant's recent orders.
|
// Distinct customers from the tenant's recent orders.
|
||||||
const orderCustomers = await db
|
const orderCustomers = await db
|
||||||
.selectDistinct({ id: customers.id })
|
.selectDistinct({ id: customers.id })
|
||||||
@@ -46,7 +46,7 @@ export async function sendStopBlast(params: {
|
|||||||
.innerJoin(orders, eq(orders.customerId, customers.id))
|
.innerJoin(orders, eq(orders.customerId, customers.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(orders.tenantId, params.brandId),
|
eq(orders.brandId, params.brandId),
|
||||||
params.channel === "sms"
|
params.channel === "sms"
|
||||||
? eq(customers.smsOptIn, true)
|
? eq(customers.smsOptIn, true)
|
||||||
: params.channel === "email"
|
: params.channel === "email"
|
||||||
@@ -81,11 +81,11 @@ export async function sendStopBlast(params: {
|
|||||||
// Persist a draft campaign for traceability. No template link — the
|
// Persist a draft campaign for traceability. No template link — the
|
||||||
// stop-blast content is supplied inline and not yet modeled.
|
// stop-blast content is supplied inline and not yet modeled.
|
||||||
const bodyHtml = `<p style="white-space:pre-wrap">${escapeHtml(params.body)}</p>`;
|
const bodyHtml = `<p style="white-space:pre-wrap">${escapeHtml(params.body)}</p>`;
|
||||||
const inserted = await withTenant(params.brandId, async (db) => {
|
const inserted = await withBrand(params.brandId, async (db) => {
|
||||||
const template = await db
|
const template = await db
|
||||||
.insert(emailTemplates)
|
.insert(emailTemplates)
|
||||||
.values({
|
.values({
|
||||||
tenantId: params.brandId,
|
brandId: params.brandId,
|
||||||
name: `Stop blast ${new Date().toISOString()}`,
|
name: `Stop blast ${new Date().toISOString()}`,
|
||||||
subject: params.subject ?? "Pickup update",
|
subject: params.subject ?? "Pickup update",
|
||||||
bodyHtml,
|
bodyHtml,
|
||||||
@@ -96,7 +96,7 @@ export async function sendStopBlast(params: {
|
|||||||
const campaign = await db
|
const campaign = await db
|
||||||
.insert(campaigns)
|
.insert(campaigns)
|
||||||
.values({
|
.values({
|
||||||
tenantId: params.brandId,
|
brandId: params.brandId,
|
||||||
name: `Stop blast ${new Date().toISOString()}`,
|
name: `Stop blast ${new Date().toISOString()}`,
|
||||||
templateId: tplId,
|
templateId: tplId,
|
||||||
status: "sent",
|
status: "sent",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { and, desc, eq, isNotNull } from "drizzle-orm";
|
import { and, desc, eq, isNotNull } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { customers, orders, campaigns } from "@/db/schema";
|
import { customers, orders, campaigns } from "@/db/schema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,13 +57,13 @@ export async function getStopMessagingData(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [orderRows, campaignRows] = await withTenant(brandId, async (db) => {
|
const [orderRows, campaignRows] = await withBrand(brandId, async (db) => {
|
||||||
const o = await db
|
const o = await db
|
||||||
.select({
|
.select({
|
||||||
id: orders.id,
|
id: orders.id,
|
||||||
status: orders.status,
|
status: orders.status,
|
||||||
placedAt: orders.placedAt,
|
placedAt: orders.placedAt,
|
||||||
customerName: customers.name,
|
customerName: customers.fullName,
|
||||||
customerEmail: customers.email,
|
customerEmail: customers.email,
|
||||||
customerPhone: customers.phone,
|
customerPhone: customers.phone,
|
||||||
})
|
})
|
||||||
@@ -71,7 +71,7 @@ export async function getStopMessagingData(params: {
|
|||||||
.innerJoin(customers, eq(customers.id, orders.customerId))
|
.innerJoin(customers, eq(customers.id, orders.customerId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(orders.tenantId, brandId),
|
eq(orders.brandId, brandId),
|
||||||
isNotNull(customers.email),
|
isNotNull(customers.email),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -86,7 +86,7 @@ export async function getStopMessagingData(params: {
|
|||||||
updatedAt: campaigns.updatedAt,
|
updatedAt: campaigns.updatedAt,
|
||||||
})
|
})
|
||||||
.from(campaigns)
|
.from(campaigns)
|
||||||
.where(eq(campaigns.tenantId, brandId))
|
.where(eq(campaigns.brandId, brandId))
|
||||||
.orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt))
|
.orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt))
|
||||||
.limit(10);
|
.limit(10);
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ export async function getStopMessagingData(params: {
|
|||||||
// schema; treat "fulfilled" status as picked up).
|
// schema; treat "fulfilled" status as picked up).
|
||||||
const mappedOrders: StopOrder[] = orderRows.map((o) => ({
|
const mappedOrders: StopOrder[] = orderRows.map((o) => ({
|
||||||
id: o.id,
|
id: o.id,
|
||||||
customer_name: o.customerName,
|
customer_name: o.customerName ?? "",
|
||||||
customer_email: o.customerEmail,
|
customer_email: o.customerEmail,
|
||||||
customer_phone: o.customerPhone,
|
customer_phone: o.customerPhone,
|
||||||
pickup_complete: o.status === "fulfilled",
|
pickup_complete: o.status === "fulfilled",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import { emailTemplates } from "@/db/schema";
|
import { emailTemplates } from "@/db/schema";
|
||||||
|
|
||||||
export type TemplateType = "email_template" | "sms_template" | "internal_note";
|
export type TemplateType = "email_template" | "sms_template" | "internal_note";
|
||||||
@@ -20,7 +20,7 @@ export type CampaignType = "marketing" | "operational" | "transactional";
|
|||||||
*/
|
*/
|
||||||
export type Template = {
|
export type Template = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string;
|
brand_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
body_text: string;
|
body_text: string;
|
||||||
@@ -42,7 +42,7 @@ export type UpsertTemplateResult = {
|
|||||||
function rowToTemplate(row: typeof emailTemplates.$inferSelect): Template {
|
function rowToTemplate(row: typeof emailTemplates.$inferSelect): Template {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
tenant_id: row.tenantId,
|
brand_id: row.brandId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
subject: row.subject,
|
subject: row.subject,
|
||||||
body_text: stripHtml(row.bodyHtml),
|
body_text: stripHtml(row.bodyHtml),
|
||||||
@@ -79,7 +79,7 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = activeBrandId
|
const rows = activeBrandId
|
||||||
? await withTenant(activeBrandId, (db) =>
|
? await withBrand(activeBrandId, (db) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(emailTemplates)
|
.from(emailTemplates)
|
||||||
@@ -121,7 +121,7 @@ export async function upsertTemplate(params: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const row = params.id
|
const row = params.id
|
||||||
? await withTenant(params.brand_id, async (db) => {
|
? await withBrand(params.brand_id, async (db) => {
|
||||||
const updated = await db
|
const updated = await db
|
||||||
.update(emailTemplates)
|
.update(emailTemplates)
|
||||||
.set({
|
.set({
|
||||||
@@ -133,17 +133,17 @@ export async function upsertTemplate(params: {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(emailTemplates.id, params.id!),
|
eq(emailTemplates.id, params.id!),
|
||||||
eq(emailTemplates.tenantId, params.brand_id),
|
eq(emailTemplates.brandId, params.brand_id),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
return updated[0] ?? null;
|
return updated[0] ?? null;
|
||||||
})
|
})
|
||||||
: await withTenant(params.brand_id, async (db) => {
|
: await withBrand(params.brand_id, async (db) => {
|
||||||
const inserted = await db
|
const inserted = await db
|
||||||
.insert(emailTemplates)
|
.insert(emailTemplates)
|
||||||
.values({
|
.values({
|
||||||
tenantId: params.brand_id,
|
brandId: params.brand_id,
|
||||||
name: params.name,
|
name: params.name,
|
||||||
subject: params.subject,
|
subject: params.subject,
|
||||||
bodyHtml,
|
bodyHtml,
|
||||||
@@ -170,14 +170,14 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const row = activeBrandId
|
const row = activeBrandId
|
||||||
? await withTenant(activeBrandId, (db) =>
|
? await withBrand(activeBrandId, (db) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(emailTemplates)
|
.from(emailTemplates)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(emailTemplates.id, templateId),
|
eq(emailTemplates.id, templateId),
|
||||||
eq(emailTemplates.tenantId, activeBrandId),
|
eq(emailTemplates.brandId, activeBrandId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -194,7 +194,7 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
|
|||||||
|
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
if (adminUser.role === "brand_admin" && row.tenantId !== adminUser.brand_id) {
|
if (adminUser.role === "brand_admin" && row.brandId !== adminUser.brand_id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
import { campaigns } from "@/db/schema";
|
import { campaigns } from "@/db/schema";
|
||||||
import type { AudienceRules } from "@/actions/communications/campaigns";
|
import type { AudienceRules } from "@/actions/communications/campaigns";
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ export type CampaignAnalytics = {
|
|||||||
function rowToCampaign(row: typeof campaigns.$inferSelect): Campaign {
|
function rowToCampaign(row: typeof campaigns.$inferSelect): Campaign {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
brand_id: row.tenantId,
|
brand_id: row.brandId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
subject: null,
|
subject: null,
|
||||||
body_text: null,
|
body_text: null,
|
||||||
@@ -84,7 +84,7 @@ export async function getHarvestReachCampaigns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await withTenant(brandId, (db) =>
|
const rows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(campaigns)
|
.from(campaigns)
|
||||||
@@ -118,13 +118,13 @@ export async function getCampaignAnalytics(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = campaignId
|
const rows = campaignId
|
||||||
? await withTenant(brandId, (db) =>
|
? await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(campaigns)
|
.from(campaigns)
|
||||||
.where(eq(campaigns.id, campaignId)),
|
.where(eq(campaigns.id, campaignId)),
|
||||||
)
|
)
|
||||||
: await withTenant(brandId, (db) =>
|
: await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(campaigns)
|
.from(campaigns)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { products } from "@/db/schema";
|
import { products } from "@/db/schema";
|
||||||
|
|
||||||
export type ProductOption = {
|
export type ProductOption = {
|
||||||
@@ -27,7 +27,7 @@ export async function getProductsForSegmentPicker(
|
|||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await withTenant(brandId, (db) =>
|
const rows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: products.id,
|
id: products.id,
|
||||||
@@ -36,7 +36,7 @@ export async function getProductsForSegmentPicker(
|
|||||||
active: products.active,
|
active: products.active,
|
||||||
})
|
})
|
||||||
.from(products)
|
.from(products)
|
||||||
.where(and(eq(products.tenantId, brandId), eq(products.active, true))),
|
.where(and(eq(products.brandId, brandId), eq(products.active, true))),
|
||||||
);
|
);
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { and, eq, ilike, or, SQL, sql } from "drizzle-orm";
|
import { and, eq, ilike, or, SQL, sql } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { customers, brandSettings } from "@/db/schema";
|
import { customers, brandSettings } from "@/db/schema";
|
||||||
import type { AudienceRules } from "@/actions/communications/campaigns";
|
import type { AudienceRules } from "@/actions/communications/campaigns";
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export type Segment = {
|
|||||||
export type CustomerSample = {
|
export type CustomerSample = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
fullName: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
};
|
};
|
||||||
@@ -70,11 +70,11 @@ function isSegmentList(v: unknown): v is Segment[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSegments(brandId: string): Promise<Segment[]> {
|
async function loadSegments(brandId: string): Promise<Segment[]> {
|
||||||
const rows = await withTenant(brandId, (db) =>
|
const rows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({ flags: brandSettings.featureFlags })
|
.select({ flags: brandSettings.featureFlags })
|
||||||
.from(brandSettings)
|
.from(brandSettings)
|
||||||
.where(eq(brandSettings.tenantId, brandId))
|
.where(eq(brandSettings.brandId, brandId))
|
||||||
.limit(1),
|
.limit(1),
|
||||||
);
|
);
|
||||||
const raw = (rows[0]?.flags ?? {}) as Record<string, unknown>;
|
const raw = (rows[0]?.flags ?? {}) as Record<string, unknown>;
|
||||||
@@ -83,11 +83,11 @@ async function loadSegments(brandId: string): Promise<Segment[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
|
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
|
||||||
await withTenant(brandId, async (db) => {
|
await withBrand(brandId, async (db) => {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select({ flags: brandSettings.featureFlags })
|
.select({ flags: brandSettings.featureFlags })
|
||||||
.from(brandSettings)
|
.from(brandSettings)
|
||||||
.where(eq(brandSettings.tenantId, brandId))
|
.where(eq(brandSettings.brandId, brandId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
||||||
const nextFlags: Record<string, unknown> = {
|
const nextFlags: Record<string, unknown> = {
|
||||||
@@ -97,7 +97,7 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise<void>
|
|||||||
await db
|
await db
|
||||||
.update(brandSettings)
|
.update(brandSettings)
|
||||||
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
||||||
.where(eq(brandSettings.tenantId, brandId));
|
.where(eq(brandSettings.brandId, brandId));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,15 +221,15 @@ export async function previewSegmentWithCustomers(
|
|||||||
}
|
}
|
||||||
void rules;
|
void rules;
|
||||||
|
|
||||||
const conds: SQL[] = [eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)];
|
const conds: SQL[] = [eq(customers.brandId, brandId), eq(customers.emailOptIn, true)];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [samples, counts] = await withTenant(brandId, async (db) => {
|
const [samples, counts] = await withBrand(brandId, async (db) => {
|
||||||
const sample = await db
|
const sample = await db
|
||||||
.select({
|
.select({
|
||||||
id: customers.id,
|
id: customers.id,
|
||||||
email: customers.email,
|
email: customers.email,
|
||||||
name: customers.name,
|
fullName: customers.fullName,
|
||||||
phone: customers.phone,
|
phone: customers.phone,
|
||||||
})
|
})
|
||||||
.from(customers)
|
.from(customers)
|
||||||
@@ -247,7 +247,7 @@ export async function previewSegmentWithCustomers(
|
|||||||
sample_customers: samples.map((s) => ({
|
sample_customers: samples.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
email: s.email ?? "",
|
email: s.email ?? "",
|
||||||
name: s.name,
|
fullName: s.fullName,
|
||||||
tags: [],
|
tags: [],
|
||||||
phone: s.phone,
|
phone: s.phone,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { stops } from "@/db/schema";
|
import { stops } from "@/db/schema";
|
||||||
|
|
||||||
export type StopOption = {
|
export type StopOption = {
|
||||||
@@ -51,29 +51,28 @@ export async function getStopsForSegmentPicker(
|
|||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await withTenant(brandId, (db) =>
|
const rows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: stops.id,
|
id: stops.id,
|
||||||
name: stops.name,
|
name: stops.name,
|
||||||
address: stops.address,
|
address: stops.address,
|
||||||
schedule: stops.schedule,
|
date: stops.date,
|
||||||
|
time: stops.time,
|
||||||
})
|
})
|
||||||
.from(stops)
|
.from(stops)
|
||||||
.where(
|
.where(
|
||||||
stopId
|
stopId
|
||||||
? and(eq(stops.tenantId, brandId), eq(stops.id, stopId))
|
? and(eq(stops.brandId, brandId), eq(stops.id, stopId))
|
||||||
: eq(stops.tenantId, brandId),
|
: eq(stops.brandId, brandId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return rows.map((r) => {
|
return rows.map((r) => {
|
||||||
const { city, state, zip } = parseAddress(r.address);
|
const { city, state, zip } = parseAddress(r.address ?? "");
|
||||||
const sched = Array.isArray(r.schedule) ? r.schedule : [];
|
const dateStr = r.date ?? "";
|
||||||
const first = sched[0] as { date?: string; time?: string } | undefined;
|
const timeStr = r.time ?? "";
|
||||||
const dateStr = first?.date ?? "";
|
|
||||||
const timeStr = first?.time ?? "";
|
|
||||||
const ts = dateStr ? new Date(dateStr).getTime() : NaN;
|
const ts = dateStr ? new Date(dateStr).getTime() : NaN;
|
||||||
const isUpcoming = Number.isFinite(ts) ? ts >= now : false;
|
const isUpcoming = Number.isFinite(ts) ? ts >= now : false;
|
||||||
const isPast = Number.isFinite(ts) ? ts < now : false;
|
const isPast = Number.isFinite(ts) ? ts < now : false;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function importOrdersBatch(
|
|||||||
|
|
||||||
// Fetch product prices for the brand.
|
// Fetch product prices for the brand.
|
||||||
const productRes = await pool.query<{ id: string; price_cents: number }>(
|
const productRes = await pool.query<{ id: string; price_cents: number }>(
|
||||||
`SELECT id, price_cents FROM products WHERE tenant_id = $1 AND id = ANY($2::uuid[])`,
|
`SELECT id, price_cents FROM products WHERE brand_id = $1 AND id = ANY($2::uuid[])`,
|
||||||
[brandId, productIds]
|
[brandId, productIds]
|
||||||
);
|
);
|
||||||
const priceMap = new Map(productRes.rows.map((p) => [p.id, p.price_cents]));
|
const priceMap = new Map(productRes.rows.map((p) => [p.id, p.price_cents]));
|
||||||
@@ -76,11 +76,11 @@ export async function importOrdersBatch(
|
|||||||
|
|
||||||
// Insert in a single transaction: customers + orders + order_items.
|
// Insert in a single transaction: customers + orders + order_items.
|
||||||
await withTx(async (client) => {
|
await withTx(async (client) => {
|
||||||
// Upsert customer by (tenant_id, email).
|
// Upsert customer by (brand_id, email).
|
||||||
const customerRes = await client.query<{ id: string }>(
|
const customerRes = await client.query<{ id: string }>(
|
||||||
`INSERT INTO customers (tenant_id, name, email, phone, email_opt_in)
|
`INSERT INTO customers (brand_id, name, email, phone, email_opt_in)
|
||||||
VALUES ($1, $2, $3, $4, true)
|
VALUES ($1, $2, $3, $4, true)
|
||||||
ON CONFLICT (tenant_id, email) WHERE email IS NOT NULL
|
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL
|
||||||
DO UPDATE SET name = EXCLUDED.name, phone = EXCLUDED.phone, updated_at = now()
|
DO UPDATE SET name = EXCLUDED.name, phone = EXCLUDED.phone, updated_at = now()
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[brandId, order.customer_name, order.customer_email || null, order.customer_phone || null]
|
[brandId, order.customer_name, order.customer_email || null, order.customer_phone || null]
|
||||||
@@ -88,7 +88,7 @@ export async function importOrdersBatch(
|
|||||||
const customerId = customerRes.rows[0]?.id ?? null;
|
const customerId = customerRes.rows[0]?.id ?? null;
|
||||||
|
|
||||||
const orderRes = await client.query<{ id: string }>(
|
const orderRes = await client.query<{ id: string }>(
|
||||||
`INSERT INTO orders (tenant_id, customer_id, total_cents, status, fulfillment)
|
`INSERT INTO orders (brand_id, customer_id, total_cents, status, fulfillment)
|
||||||
VALUES ($1, $2, $3, 'pending', $4)
|
VALUES ($1, $2, $3, 'pending', $4)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[brandId, customerId, totalCents, fulfillment]
|
[brandId, customerId, totalCents, fulfillment]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { products } from "@/db/schema";
|
import { products } from "@/db/schema";
|
||||||
|
|
||||||
export type ImportProductsResult =
|
export type ImportProductsResult =
|
||||||
@@ -45,9 +45,9 @@ export async function importProductsBatch(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await withTenant(brandId, (db) =>
|
await withBrand(brandId, (db) =>
|
||||||
db.insert(products).values({
|
db.insert(products).values({
|
||||||
tenantId: brandId,
|
brandId: brandId,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
description: p.description ?? null,
|
description: p.description ?? null,
|
||||||
priceCents,
|
priceCents,
|
||||||
|
|||||||
+1
-61
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
import { mockOrders, mockStops } from "@/lib/mock-data";
|
|
||||||
|
|
||||||
type AdminOrder = {
|
export type AdminOrder = {
|
||||||
id: string;
|
id: string;
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
customer_email: string | null;
|
customer_email: string | null;
|
||||||
@@ -106,46 +105,7 @@ type AdminOrderDetail = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock orders for UI review
|
|
||||||
const mockAdminOrders: AdminOrder[] = mockOrders.map((o: any) => ({
|
|
||||||
id: o.id,
|
|
||||||
customer_name: o.customer_name,
|
|
||||||
customer_email: o.customer_email,
|
|
||||||
customer_phone: o.customer_phone,
|
|
||||||
stop_id: o.stop_id,
|
|
||||||
status: o.status,
|
|
||||||
subtotal: o.subtotal,
|
|
||||||
pickup_complete: o.pickup_complete,
|
|
||||||
pickup_completed_at: o.pickup_completed_at,
|
|
||||||
pickup_completed_by: null,
|
|
||||||
created_at: o.created_at,
|
|
||||||
payment_processor: o.payment_processor,
|
|
||||||
stops: o.stops,
|
|
||||||
order_items: o.order_items,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockAdminStops: AdminStop[] = mockStops.map((s: any) => ({
|
|
||||||
id: s.id,
|
|
||||||
city: s.city,
|
|
||||||
state: s.state,
|
|
||||||
date: s.date,
|
|
||||||
location: s.location,
|
|
||||||
brand_id: s.brand_id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
|
||||||
|
|
||||||
export async function getAdminOrders(): Promise<AdminOrdersResponse> {
|
export async function getAdminOrders(): Promise<AdminOrdersResponse> {
|
||||||
// Return mock data in mock mode
|
|
||||||
if (useMockData) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
orders: mockAdminOrders,
|
|
||||||
stops: mockAdminStops,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
const brandId = adminUser?.brand_id ?? null;
|
const brandId = adminUser?.brand_id ?? null;
|
||||||
|
|
||||||
@@ -193,26 +153,6 @@ export async function getAdminPickedUpOrders(): Promise<AdminOrder[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDetail | null> {
|
export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDetail | null> {
|
||||||
// Return mock order detail in mock mode
|
|
||||||
if (useMockData) {
|
|
||||||
const order = mockAdminOrders.find((o) => o.id === orderId);
|
|
||||||
if (!order) return null;
|
|
||||||
return {
|
|
||||||
...order,
|
|
||||||
discount_amount: null,
|
|
||||||
tax_amount: order.subtotal * 0.08,
|
|
||||||
tax_rate: 0.08,
|
|
||||||
tax_location: "Colorado",
|
|
||||||
discount_reason: null,
|
|
||||||
internal_notes: null,
|
|
||||||
payment_status: "paid",
|
|
||||||
payment_transaction_id: `txn_mock_${orderId}`,
|
|
||||||
refunded_amount: 0,
|
|
||||||
refund_reason: null,
|
|
||||||
refunds: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
const brandId = adminUser?.brand_id ?? null;
|
const brandId = adminUser?.brand_id ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ export type AdminCreateOrderInput = {
|
|||||||
discount_reason?: string | null;
|
discount_reason?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Type for the created order from the RPC
|
||||||
|
export type CreatedOrder = {
|
||||||
|
id: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminCreateOrderResult =
|
export type AdminCreateOrderResult =
|
||||||
| { success: true; orderId: string; order: any }
|
| { success: true; orderId: string; order: CreatedOrder }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
export async function createAdminOrder(
|
export async function createAdminOrder(
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getMockTableData } from "@/lib/mock-data";
|
import { withBrand } from "@/db/client";
|
||||||
import { withTenant } from "@/db/client";
|
|
||||||
import { products } from "@/db/schema";
|
import { products } from "@/db/schema";
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
|
||||||
|
|
||||||
export type CreateProductResult =
|
export type CreateProductResult =
|
||||||
| { success: true; id: string }
|
| { success: true; id: string }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
@@ -32,25 +29,6 @@ export async function createProduct(
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useMockData) {
|
|
||||||
const mockProducts = getMockTableData("products") as any[];
|
|
||||||
const newId = `mock-prod-${Date.now()}`;
|
|
||||||
const newProduct = {
|
|
||||||
id: newId,
|
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
price: data.price,
|
|
||||||
type: data.type,
|
|
||||||
is_active: data.active,
|
|
||||||
image_url: data.image_url ?? null,
|
|
||||||
is_taxable: data.is_taxable,
|
|
||||||
pickup_type: data.pickup_type ?? "scheduled_stop",
|
|
||||||
brand_id: brandId,
|
|
||||||
};
|
|
||||||
mockProducts.push(newProduct);
|
|
||||||
return { success: true, id: newId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// The new schema stores products with `price_cents` (integer) and no `type`,
|
// The new schema stores products with `price_cents` (integer) and no `type`,
|
||||||
// `is_taxable`, `pickup_type`, or `image_url` column. `image_url` is now
|
// `is_taxable`, `pickup_type`, or `image_url` column. `image_url` is now
|
||||||
// attached via the `product_images` table; `type` / `is_taxable` / `pickup_type`
|
// attached via the `product_images` table; `type` / `is_taxable` / `pickup_type`
|
||||||
@@ -62,11 +40,11 @@ export async function createProduct(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inserted = await withTenant(brandId, async (db) => {
|
const inserted = await withBrand(brandId, async (db) => {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(products)
|
.insert(products)
|
||||||
.values({
|
.values({
|
||||||
tenantId: brandId,
|
brandId: brandId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description ?? null,
|
description: data.description ?? null,
|
||||||
priceCents,
|
priceCents,
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getMockTableData } from "@/lib/mock-data";
|
import { withBrand } from "@/db/client";
|
||||||
import { withTenant } from "@/db/client";
|
|
||||||
import { products } from "@/db/schema";
|
import { products } from "@/db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
|
||||||
|
|
||||||
export type UpdateProductResult =
|
export type UpdateProductResult =
|
||||||
| { success: true }
|
| { success: true }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
@@ -34,10 +31,6 @@ export async function updateProduct(
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useMockData) {
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// The new schema has `price_cents` (integer cents) and no `type`,
|
// The new schema has `price_cents` (integer cents) and no `type`,
|
||||||
// `is_taxable`, `pickup_type`, or `image_url` column. `type` /
|
// `is_taxable`, `pickup_type`, or `image_url` column. `type` /
|
||||||
// `is_taxable` / `pickup_type` are dropped for the SaaS rebuild;
|
// `is_taxable` / `pickup_type` are dropped for the SaaS rebuild;
|
||||||
@@ -48,7 +41,7 @@ export async function updateProduct(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withTenant(brandId, (db) =>
|
await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.update(products)
|
.update(products)
|
||||||
.set({
|
.set({
|
||||||
@@ -58,7 +51,7 @@ export async function updateProduct(
|
|||||||
active: data.active,
|
active: data.active,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(and(eq(products.id, productId), eq(products.tenantId, brandId)))
|
.where(and(eq(products.id, productId), eq(products.brandId, brandId)))
|
||||||
);
|
);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { productImages } from "@/db/schema";
|
import { productImages } from "@/db/schema";
|
||||||
|
import { uploadObject, BUCKETS } from "@/lib/storage";
|
||||||
|
|
||||||
export type UploadProductImageResult =
|
export type UploadProductImageResult =
|
||||||
| { success: true; imageUrl: string }
|
| { success: true; imageUrl: string }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
// TODO(migration): product images in the new SaaS schema live in the
|
|
||||||
// `product_images` table (storage_key + position + alt_text) backed by
|
|
||||||
// the `files` table. Supabase Storage is gone, so we no longer upload
|
|
||||||
// to `/storage/v1/object/...` — that pathway is stubbed. Callers that
|
|
||||||
// upload images should write to the `files` table via an S3-compatible
|
|
||||||
// backend (still TODO). This stub persists the intended storage key as
|
|
||||||
// a record so the UI can continue to render an image URL placeholder.
|
|
||||||
export async function uploadProductImage(
|
export async function uploadProductImage(
|
||||||
productId: string,
|
productId: string,
|
||||||
file: File
|
file: File
|
||||||
@@ -33,16 +27,16 @@ export async function uploadProductImage(
|
|||||||
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
||||||
const storageKey = `products/${productId}/${crypto.randomUUID()}.${ext}`;
|
const storageKey = `products/${productId}/${crypto.randomUUID()}.${ext}`;
|
||||||
|
|
||||||
// Without a configured object store, we cannot actually upload bytes.
|
|
||||||
// We still record the planned storage key in `product_images` so the
|
|
||||||
// schema-level FK + ordering are exercised. The actual upload will be
|
|
||||||
// re-introduced when the S3-compatible store lands.
|
|
||||||
if (productId === "__NEW__") {
|
|
||||||
return { success: false, error: "Object store not configured; cannot upload images yet" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const imageUrl = await uploadObject({
|
||||||
|
bucket: BUCKETS.PRODUCTS,
|
||||||
|
key: storageKey,
|
||||||
|
body: buffer,
|
||||||
|
contentType: file.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
await withBrand(adminUser.brand_id ?? "__missing__", (db) =>
|
||||||
db.insert(productImages).values({
|
db.insert(productImages).values({
|
||||||
productId,
|
productId,
|
||||||
storageKey,
|
storageKey,
|
||||||
@@ -50,7 +44,8 @@ export async function uploadProductImage(
|
|||||||
altText: null,
|
altText: null,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return { success: true, imageUrl: `/storage/${storageKey}` };
|
|
||||||
|
return { success: true, imageUrl };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
|
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
|
||||||
}
|
}
|
||||||
@@ -66,7 +61,7 @@ export async function deleteProductImage(
|
|||||||
// from `product_images` for this product. The legacy `image_url` PATCH
|
// from `product_images` for this product. The legacy `image_url` PATCH
|
||||||
// pathway is gone (that column no longer exists on `products`).
|
// pathway is gone (that column no longer exists on `products`).
|
||||||
try {
|
try {
|
||||||
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
|
await withBrand(adminUser.brand_id ?? "__missing__", (db) =>
|
||||||
db.delete(productImages).where(eq(productImages.productId, productId))
|
db.delete(productImages).where(eq(productImages.productId, productId))
|
||||||
);
|
);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export type CampaignActivityRow = {
|
|||||||
|
|
||||||
/** Substitute the brandId into the SQL — null = platform admin (all brands). */
|
/** Substitute the brandId into the SQL — null = platform admin (all brands). */
|
||||||
function brandClause(brandId: string | null, tableAlias = "o"): string {
|
function brandClause(brandId: string | null, tableAlias = "o"): string {
|
||||||
return brandId ? `AND ${tableAlias}.tenant_id = $3::uuid` : "";
|
return brandId ? `AND ${tableAlias}.brand_id = $3::uuid` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function brandParams(brandId: string | null): unknown[] {
|
function brandParams(brandId: string | null): unknown[] {
|
||||||
@@ -235,7 +235,7 @@ export async function getContactGrowthReport(range: DateRange, brandId: string |
|
|||||||
FROM generate_series($1::date, $2::date, '1 day'::interval) d
|
FROM generate_series($1::date, $2::date, '1 day'::interval) d
|
||||||
LEFT JOIN customers c
|
LEFT JOIN customers c
|
||||||
ON c.created_at::date = d::date
|
ON c.created_at::date = d::date
|
||||||
${effectiveBrandId ? "AND c.tenant_id = $3::uuid" : ""}
|
${effectiveBrandId ? "AND c.brand_id = $3::uuid" : ""}
|
||||||
GROUP BY d::date
|
GROUP BY d::date
|
||||||
ORDER BY d::date DESC`,
|
ORDER BY d::date DESC`,
|
||||||
[range.start, range.end, ...brandParams(effectiveBrandId)]
|
[range.start, range.end, ...brandParams(effectiveBrandId)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { brandSettings } from "@/db/schema";
|
import { brandSettings } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
@@ -36,11 +36,11 @@ export async function toggleBrandFeature(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withTenant(brandId, async (db) => {
|
await withBrand(brandId, async (db) => {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select({ featureFlags: brandSettings.featureFlags })
|
.select({ featureFlags: brandSettings.featureFlags })
|
||||||
.from(brandSettings)
|
.from(brandSettings)
|
||||||
.where(eq(brandSettings.tenantId, brandId))
|
.where(eq(brandSettings.brandId, brandId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const current = (existing[0]?.featureFlags ?? {}) as Record<string, unknown>;
|
const current = (existing[0]?.featureFlags ?? {}) as Record<string, unknown>;
|
||||||
const next = { ...current, [featureKey]: enabled };
|
const next = { ...current, [featureKey]: enabled };
|
||||||
@@ -48,12 +48,12 @@ export async function toggleBrandFeature(
|
|||||||
// No row yet — bootstrap with the brand name from tenants.
|
// No row yet — bootstrap with the brand name from tenants.
|
||||||
await db
|
await db
|
||||||
.insert(brandSettings)
|
.insert(brandSettings)
|
||||||
.values({ tenantId: brandId, brandName: "Brand", featureFlags: next });
|
.values({ brandId: brandId, featureFlags: next });
|
||||||
} else {
|
} else {
|
||||||
await db
|
await db
|
||||||
.update(brandSettings)
|
.update(brandSettings)
|
||||||
.set({ featureFlags: next, updatedAt: new Date() })
|
.set({ featureFlags: next, updatedAt: new Date() })
|
||||||
.where(eq(brandSettings.tenantId, brandId));
|
.where(eq(brandSettings.brandId, brandId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
|||||||
status: string;
|
status: string;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
tenant_id: string;
|
brand_id: string;
|
||||||
}>(
|
}>(
|
||||||
`SELECT
|
`SELECT
|
||||||
o.id::text AS id,
|
o.id::text AS id,
|
||||||
@@ -79,7 +79,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
|||||||
o.status,
|
o.status,
|
||||||
o.total_cents::float / 100.0 AS subtotal,
|
o.total_cents::float / 100.0 AS subtotal,
|
||||||
o.placed_at::text AS created_at,
|
o.placed_at::text AS created_at,
|
||||||
o.tenant_id::text AS tenant_id
|
o.brand_id::text AS brand_id
|
||||||
FROM orders o
|
FROM orders o
|
||||||
LEFT JOIN customers c ON c.id = o.customer_id
|
LEFT JOIN customers c ON c.id = o.customer_id
|
||||||
WHERE o.fulfillment IN ('ship', 'mixed')
|
WHERE o.fulfillment IN ('ship', 'mixed')
|
||||||
@@ -97,7 +97,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
|||||||
shipping_status: "pending",
|
shipping_status: "pending",
|
||||||
tracking_number: null,
|
tracking_number: null,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
brand_id: r.tenant_id,
|
brand_id: r.brand_id,
|
||||||
order_items: [],
|
order_items: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -135,10 +135,10 @@ export async function createFedExShipment(
|
|||||||
// than fabricate a fake address.
|
// than fabricate a fake address.
|
||||||
const { rows: orderRows } = await pool.query<{
|
const { rows: orderRows } = await pool.query<{
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string | null;
|
brand_id: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
}>(
|
}>(
|
||||||
`SELECT id::text AS id, tenant_id::text AS tenant_id, status
|
`SELECT id::text AS id, brand_id::text AS brand_id, status
|
||||||
FROM orders WHERE id = $1 LIMIT 1`,
|
FROM orders WHERE id = $1 LIMIT 1`,
|
||||||
[orderId]
|
[orderId]
|
||||||
);
|
);
|
||||||
@@ -147,10 +147,10 @@ export async function createFedExShipment(
|
|||||||
if (!order) return { success: false, error: "Order not found" };
|
if (!order) return { success: false, error: "Order not found" };
|
||||||
|
|
||||||
// The order's brand is the source of truth. Validate the admin has access.
|
// The order's brand is the source of truth. Validate the admin has access.
|
||||||
if (order.tenant_id) {
|
if (order.brand_id) {
|
||||||
try { assertBrandAccess(adminUser, order.tenant_id); } catch { return { success: false, error: "Brand access denied" }; }
|
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
}
|
}
|
||||||
const effectiveBrandId = order.tenant_id ?? adminUser.brand_id ?? null;
|
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||||
if (!effectiveBrandId) {
|
if (!effectiveBrandId) {
|
||||||
return { success: false, error: "Brand required for shipping" };
|
return { success: false, error: "Brand required for shipping" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,10 +165,10 @@ export async function getFedExRates(
|
|||||||
// meaningful error rather than silently call the API with junk.
|
// meaningful error rather than silently call the API with junk.
|
||||||
const { rows: orderRows } = await pool.query<{
|
const { rows: orderRows } = await pool.query<{
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string | null;
|
brand_id: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
}>(
|
}>(
|
||||||
`SELECT id::text AS id, tenant_id::text AS tenant_id, status
|
`SELECT id::text AS id, brand_id::text AS brand_id, status
|
||||||
FROM orders WHERE id = $1 LIMIT 1`,
|
FROM orders WHERE id = $1 LIMIT 1`,
|
||||||
[orderId]
|
[orderId]
|
||||||
);
|
);
|
||||||
@@ -176,10 +176,10 @@ export async function getFedExRates(
|
|||||||
if (!order) return { success: false, error: "Order not found" };
|
if (!order) return { success: false, error: "Order not found" };
|
||||||
|
|
||||||
// The order's brand is the source of truth. Validate the admin has access.
|
// The order's brand is the source of truth. Validate the admin has access.
|
||||||
if (order.tenant_id) {
|
if (order.brand_id) {
|
||||||
try { assertBrandAccess(adminUser, order.tenant_id); } catch { return { success: false, error: "Brand access denied" }; }
|
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
}
|
}
|
||||||
const effectiveBrandId = order.tenant_id ?? adminUser.brand_id ?? null;
|
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||||
if (!effectiveBrandId) {
|
if (!effectiveBrandId) {
|
||||||
return { success: false, error: "Brand required for shipping" };
|
return { success: false, error: "Brand required for shipping" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getPaymentSettings } from "@/actions/payments";
|
import { getPaymentSettings } from "@/actions/payments";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { products } from "@/db/schema";
|
import { products } from "@/db/schema";
|
||||||
import { inArray } from "drizzle-orm";
|
import { inArray } from "drizzle-orm";
|
||||||
|
|
||||||
function getSquareBaseUrl(accessToken: string) {
|
function getSquareBaseUrl() {
|
||||||
return process.env.SQUARE_ENVIRONMENT === "production"
|
return process.env.SQUARE_ENVIRONMENT === "production"
|
||||||
? "https://connect.squareup.com"
|
? "https://connect.squareup.com"
|
||||||
: "https://connect.squareupsandbox.com";
|
: "https://connect.squareupsandbox.com";
|
||||||
@@ -16,7 +16,7 @@ async function getSquareCatalogItemVariation(
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
catalogObjectId: string
|
catalogObjectId: string
|
||||||
) {
|
) {
|
||||||
const baseUrl = getSquareBaseUrl(accessToken);
|
const baseUrl = getSquareBaseUrl();
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${baseUrl}/v2/catalog/object/${catalogObjectId}`,
|
`${baseUrl}/v2/catalog/object/${catalogObjectId}`,
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,7 @@ async function batchUpdateSquareInventory(
|
|||||||
type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT";
|
type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT";
|
||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
const baseUrl = getSquareBaseUrl(accessToken);
|
const baseUrl = getSquareBaseUrl();
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${baseUrl}/v2/inventory/changes/batch-create`,
|
`${baseUrl}/v2/inventory/changes/batch-create`,
|
||||||
{
|
{
|
||||||
@@ -118,7 +118,7 @@ export async function syncInventoryToSquare(
|
|||||||
try {
|
try {
|
||||||
// Fetch product details from RC
|
// Fetch product details from RC
|
||||||
const productIds = items.map((i) => i.productId);
|
const productIds = items.map((i) => i.productId);
|
||||||
const rows = await withTenant(brandId, (db) =>
|
const rows = await withBrand(brandId, (db) =>
|
||||||
db
|
db
|
||||||
.select({ id: products.id, name: products.name })
|
.select({ id: products.id, name: products.name })
|
||||||
.from(products)
|
.from(products)
|
||||||
@@ -178,7 +178,7 @@ export async function syncInventoryFromSquare(brandId: string): Promise<SyncResu
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch Square inventory counts for the location
|
// Fetch Square inventory counts for the location
|
||||||
const baseUrl = getSquareBaseUrl(settings.square_access_token);
|
const baseUrl = getSquareBaseUrl();
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${baseUrl}/v2/inventory/${settings.square_location_id}/counts`,
|
`${baseUrl}/v2/inventory/${settings.square_location_id}/counts`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { getPaymentSettings } from "@/actions/payments";
|
import { getPaymentSettings } from "@/actions/payments";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
function getSquareBaseUrl(accessToken: string) {
|
function getSquareBaseUrl() {
|
||||||
return process.env.SQUARE_ENVIRONMENT === "production"
|
return process.env.SQUARE_ENVIRONMENT === "production"
|
||||||
? "https://connect.squareup.com"
|
? "https://connect.squareup.com"
|
||||||
: "https://connect.squareupsandbox.com";
|
: "https://connect.squareupsandbox.com";
|
||||||
@@ -15,7 +15,7 @@ async function fetchSquarePayments(
|
|||||||
locationId: string,
|
locationId: string,
|
||||||
since: string
|
since: string
|
||||||
) {
|
) {
|
||||||
const baseUrl = getSquareBaseUrl(accessToken);
|
const baseUrl = getSquareBaseUrl();
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${baseUrl}/v2/payments/list`,
|
`${baseUrl}/v2/payments/list`,
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,7 @@ async function fetchSquarePayments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSquareOrder(accessToken: string, orderId: string) {
|
async function fetchSquareOrder(accessToken: string, orderId: string) {
|
||||||
const baseUrl = getSquareBaseUrl(accessToken);
|
const baseUrl = getSquareBaseUrl();
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${baseUrl}/v2/orders/${orderId}`,
|
`${baseUrl}/v2/orders/${orderId}`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getPaymentSettings } from "@/actions/payments";
|
import { getPaymentSettings } from "@/actions/payments";
|
||||||
import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square";
|
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
export type SquareCatalogItem = {
|
export type SquareCatalogItem = {
|
||||||
@@ -14,19 +13,7 @@ export type SquareCatalogItem = {
|
|||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getSquareClient(accessToken: string) {
|
function getSquareBaseUrl() {
|
||||||
const env = process.env.SQUARE_ENVIRONMENT === "production"
|
|
||||||
? SquareEnvironment.Production
|
|
||||||
: SquareEnvironment.Sandbox;
|
|
||||||
|
|
||||||
const config: BaseClientOptions = {
|
|
||||||
token: accessToken,
|
|
||||||
environment: env,
|
|
||||||
};
|
|
||||||
return new SquareClient(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSquareBaseUrl(accessToken: string) {
|
|
||||||
const env = process.env.SQUARE_ENVIRONMENT === "production"
|
const env = process.env.SQUARE_ENVIRONMENT === "production"
|
||||||
? "https://connect.squareup.com"
|
? "https://connect.squareup.com"
|
||||||
: "https://connect.squareupsandbox.com";
|
: "https://connect.squareupsandbox.com";
|
||||||
@@ -34,7 +21,7 @@ function getSquareBaseUrl(accessToken: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSquareCatalog(accessToken: string, cursor?: string) {
|
async function fetchSquareCatalog(accessToken: string, cursor?: string) {
|
||||||
const baseUrl = getSquareBaseUrl(accessToken);
|
const baseUrl = getSquareBaseUrl();
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${baseUrl}/v2/catalog/list`,
|
`${baseUrl}/v2/catalog/list`,
|
||||||
{
|
{
|
||||||
@@ -67,7 +54,7 @@ async function upsertSquareCatalogItem(
|
|||||||
},
|
},
|
||||||
existingItemId?: string
|
existingItemId?: string
|
||||||
) {
|
) {
|
||||||
const baseUrl = getSquareBaseUrl(accessToken);
|
const baseUrl = getSquareBaseUrl();
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
idempotency_key: crypto.randomUUID(),
|
idempotency_key: crypto.randomUUID(),
|
||||||
item: {
|
item: {
|
||||||
@@ -130,7 +117,6 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
|
|||||||
return { success: false, synced: 0, errors: ["Square not connected"] };
|
return { success: false, synced: 0, errors: ["Square not connected"] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = getSquareClient(settings.square_access_token);
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let synced = 0;
|
let synced = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
import { getMockTableData } from "@/lib/mock-data";
|
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
|
||||||
|
|
||||||
export type CreateStopResult =
|
export type CreateStopResult =
|
||||||
| { success: true; id: string }
|
| { success: true; id: string }
|
||||||
@@ -33,11 +30,6 @@ export async function createStop(
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useMockData) {
|
|
||||||
const mockStops = getMockTableData("stops") as Array<{ id: string }>;
|
|
||||||
return { success: true, id: `mock-stop-${Date.now()}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// `admin_create_stop` is a SECURITY DEFINER RPC that bypasses the
|
// `admin_create_stop` is a SECURITY DEFINER RPC that bypasses the
|
||||||
// block_stops_mutations RLS policy. It returns either {success,
|
// block_stops_mutations RLS policy. It returns either {success,
|
||||||
// stop_id} or {success:false, error}. Migration 202.
|
// stop_id} or {success:false, error}. Migration 202.
|
||||||
|
|||||||
@@ -50,12 +50,6 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
|
|||||||
return { success: false, error: "Not authorized" };
|
return { success: false, error: "Not authorized" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
|
||||||
|
|
||||||
if (useMockData) {
|
|
||||||
return { success: false, error: "Stop not found" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Stop + brand — legacy schema, raw SQL (the Drizzle stops table
|
// 1. Stop + brand — legacy schema, raw SQL (the Drizzle stops table
|
||||||
// maps to the new schema which doesn't have city/state/date/etc).
|
// maps to the new schema which doesn't have city/state/date/etc).
|
||||||
const stopRes = await pool.query<StopDetail & { brand_name: string; brand_slug: string }>(
|
const stopRes = await pool.query<StopDetail & { brand_name: string; brand_slug: string }>(
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
import { logAuditEvent } from "@/actions/audit";
|
import { logAuditEvent } from "@/actions/audit";
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
|
||||||
|
|
||||||
export type UpdateStopResult =
|
export type UpdateStopResult =
|
||||||
| { success: true }
|
| { success: true }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
@@ -33,10 +31,6 @@ export async function updateStop(
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useMockData) {
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`;
|
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`;
|
||||||
|
|
||||||
// Direct UPDATE on the legacy stops table — the new-schema Drizzle
|
// Direct UPDATE on the legacy stops table — the new-schema Drizzle
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
// Stripe API version type
|
||||||
|
type StripeApiVersion = "2026-05-27.dahlia";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Stripe Connect status for a brand.
|
* Get Stripe Connect status for a brand.
|
||||||
* Checks if brand has a stripe_user_id (connected account).
|
* Checks if brand has a stripe_user_id (connected account).
|
||||||
@@ -38,7 +41,7 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
|
|||||||
return { is_connected: true, account_id: stripeUserId };
|
return { is_connected: true, account_id: stripeUserId };
|
||||||
}
|
}
|
||||||
|
|
||||||
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 account = await stripe.accounts.retrieve(stripeUserId);
|
const account = await stripe.accounts.retrieve(stripeUserId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -48,7 +51,7 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
|
|||||||
payouts_enabled: account.payouts_enabled,
|
payouts_enabled: account.payouts_enabled,
|
||||||
details_submitted: account.details_submitted,
|
details_submitted: account.details_submitted,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch {
|
||||||
// If we can't verify, assume connected but with stale data
|
// If we can't verify, assume connected but with stale data
|
||||||
return {
|
return {
|
||||||
is_connected: true,
|
is_connected: true,
|
||||||
@@ -82,7 +85,7 @@ export async function createStripeConnectLink(brandId: string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
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 origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
// Create Express account
|
// Create Express account
|
||||||
@@ -144,7 +147,7 @@ export async function refreshStripeConnectLink(brandId: string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
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 origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
const accountLink = await stripe.accountLinks.create({
|
const accountLink = await stripe.accountLinks.create({
|
||||||
@@ -234,7 +237,7 @@ export async function createStripeDashboardLink(brandId: string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
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 loginLink = await stripe.accounts.createLoginLink(status.account_id);
|
const loginLink = await stripe.accounts.createLoginLink(status.account_id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+4
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { withTenant } from "@/db/client";
|
import { withBrand } from "@/db/client";
|
||||||
import { brandSettings } from "@/db/schema";
|
import { brandSettings } from "@/db/schema";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
@@ -64,8 +64,7 @@ export async function calculateOrderTax(params: {
|
|||||||
const stripe = new Stripe(stripeKey);
|
const stripe = new Stripe(stripeKey);
|
||||||
|
|
||||||
// Build Stripe line items for tax calculation — only taxable items
|
// Build Stripe line items for tax calculation — only taxable items
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const lineItems: Stripe.Tax.CalculationCreateParams.LineItem[] = params.items
|
||||||
const lineItems: any[] = params.items
|
|
||||||
.filter((item) => item.is_taxable !== false)
|
.filter((item) => item.is_taxable !== false)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
amount: Math.round(item.price * item.quantity * 100), // cents
|
amount: Math.round(item.price * item.quantity * 100), // cents
|
||||||
@@ -120,11 +119,11 @@ export async function calculateOrderTax(params: {
|
|||||||
*/
|
*/
|
||||||
async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> {
|
async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> {
|
||||||
try {
|
try {
|
||||||
const rows = await withTenant(brandId, async (db) =>
|
const rows = await withBrand(brandId, async (db) =>
|
||||||
db
|
db
|
||||||
.select({ featureFlags: brandSettings.featureFlags })
|
.select({ featureFlags: brandSettings.featureFlags })
|
||||||
.from(brandSettings)
|
.from(brandSettings)
|
||||||
.where(eq(brandSettings.tenantId, brandId))
|
.where(eq(brandSettings.brandId, brandId))
|
||||||
.limit(1),
|
.limit(1),
|
||||||
);
|
);
|
||||||
const flags = rows[0]?.featureFlags as
|
const flags = rows[0]?.featureFlags as
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
|
|
||||||
|
|
||||||
// TODO(migration): the time-tracking admin RPCs (get_time_tracking_workers,
|
// TODO(migration): the time-tracking admin RPCs (get_time_tracking_workers,
|
||||||
// create_time_worker, reset_time_worker_pin, update_time_worker,
|
// create_time_worker, reset_time_worker_pin, update_time_worker,
|
||||||
@@ -13,15 +12,10 @@ import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
|
|||||||
// the underlying tables (`time_workers`, `time_tasks`, `time_logs`,
|
// the underlying tables (`time_workers`, `time_tasks`, `time_logs`,
|
||||||
// `time_tracking_settings`, `time_tracking_notification_log`) were
|
// `time_tracking_settings`, `time_tracking_notification_log`) were
|
||||||
// not carried over into the SaaS rebuild's `db/schema/`. The actions
|
// not carried over into the SaaS rebuild's `db/schema/`. The actions
|
||||||
// below preserve the original signatures and return mock data when
|
// below return empty results. To bring time tracking back, add
|
||||||
// `NEXT_PUBLIC_USE_MOCK_DATA === "true"`, but the real RPC paths now
|
|
||||||
// return empty/empty-list results. To bring time tracking back, add
|
|
||||||
// the tables to `db/schema/` and re-implement against Drizzle. See
|
// the tables to `db/schema/` and re-implement against Drizzle. See
|
||||||
// `actions/route-trace/lots.ts` for the same pattern.
|
// `actions/route-trace/lots.ts` for the same pattern.
|
||||||
|
|
||||||
// Mock mode flag - only enabled when explicitly set
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type TimeWorker = {
|
export type TimeWorker = {
|
||||||
@@ -69,21 +63,7 @@ export type TimeSummary = {
|
|||||||
|
|
||||||
// ── Workers ───────────────────────────────────────────────────────────────────
|
// ── Workers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorker[]> {
|
export async function getTimeTrackingWorkers(_brandId: string): Promise<TimeWorker[]> {
|
||||||
if (useMockData) {
|
|
||||||
return mockWorkers.map(w => ({
|
|
||||||
id: w.id,
|
|
||||||
brand_id: brandId,
|
|
||||||
name: w.name,
|
|
||||||
role: w.role,
|
|
||||||
lang: w.language,
|
|
||||||
pin: "0000",
|
|
||||||
active: w.is_active,
|
|
||||||
last_used_at: null,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
worker_number: null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// Time tracking tables not in SaaS rebuild — return empty list.
|
// Time tracking tables not in SaaS rebuild — return empty list.
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -125,17 +105,7 @@ export async function deleteTimeWorker(_workerId: string): Promise<{ success: bo
|
|||||||
|
|
||||||
// ── Tasks ────────────────────────────────────────────────────────────────────
|
// ── Tasks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getTimeTrackingTasks(brandId: string, activeOnly = false): Promise<TimeTask[]> {
|
export async function getTimeTrackingTasks(_brandId: string, _activeOnly = false): Promise<TimeTask[]> {
|
||||||
if (useMockData) {
|
|
||||||
return mockTasks.map(t => ({
|
|
||||||
id: t.id,
|
|
||||||
name: t.name_en,
|
|
||||||
name_es: t.name_es,
|
|
||||||
unit: t.unit,
|
|
||||||
active: true,
|
|
||||||
sort_order: t.sort_order,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +143,7 @@ export async function deleteTimeTask(_taskId: string): Promise<{ success: boolea
|
|||||||
// ── Time Logs ─────────────────────────────────────────────────────────────────
|
// ── Time Logs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getWorkerTimeLogs(
|
export async function getWorkerTimeLogs(
|
||||||
brandId: string,
|
_brandId: string,
|
||||||
_options: {
|
_options: {
|
||||||
workerId?: string;
|
workerId?: string;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
@@ -183,30 +153,6 @@ export async function getWorkerTimeLogs(
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<TimeLog[]> {
|
): Promise<TimeLog[]> {
|
||||||
if (useMockData) {
|
|
||||||
// Filter by worker, task, date range
|
|
||||||
let entries = mockTimeEntries.filter(e => e.brand_id === brandId);
|
|
||||||
if (_options.workerId) entries = entries.filter(e => e.worker_id === _options.workerId);
|
|
||||||
|
|
||||||
return entries.map(e => {
|
|
||||||
const worker = mockWorkers.find(w => w.id === e.worker_id);
|
|
||||||
const task = mockTasks.find(t => t.id === e.task_id);
|
|
||||||
return {
|
|
||||||
id: e.id,
|
|
||||||
worker_id: e.worker_id,
|
|
||||||
worker_name: worker?.name ?? "Unknown",
|
|
||||||
task_id: e.task_id,
|
|
||||||
task_name: task?.name_en ?? "Unknown",
|
|
||||||
clock_in: `${e.date}T09:00:00Z`,
|
|
||||||
clock_out: `${e.date}T${9 + e.hours}:00:00Z`,
|
|
||||||
lunch_break_minutes: 0,
|
|
||||||
notes: null,
|
|
||||||
submitted_via: "web",
|
|
||||||
total_minutes: Math.round(e.hours * 60),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,43 +176,10 @@ export async function deleteWorkerTimeLog(_logId: string): Promise<{ success: bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getTimeTrackingSummary(
|
export async function getTimeTrackingSummary(
|
||||||
brandId: string,
|
_brandId: string,
|
||||||
_start: string,
|
_start: string,
|
||||||
_end: string
|
_end: string
|
||||||
): Promise<TimeSummary> {
|
): Promise<TimeSummary> {
|
||||||
if (useMockData) {
|
|
||||||
const entries = mockTimeEntries.filter(e => e.brand_id === brandId);
|
|
||||||
|
|
||||||
// Calculate by worker
|
|
||||||
const workerMap = new Map<string, { id: string; name: string; total_hours: number; entry_count: number }>();
|
|
||||||
entries.forEach(e => {
|
|
||||||
const worker = mockWorkers.find(w => w.id === e.worker_id);
|
|
||||||
const existing = workerMap.get(e.worker_id) || { id: e.worker_id, name: worker?.name ?? "Unknown", total_hours: 0, entry_count: 0 };
|
|
||||||
existing.total_hours += e.hours;
|
|
||||||
existing.entry_count += 1;
|
|
||||||
workerMap.set(e.worker_id, existing);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate by task
|
|
||||||
const taskMap = new Map<string, { id: string; name: string; name_es: string | null; total_hours: number; entry_count: number }>();
|
|
||||||
entries.forEach(e => {
|
|
||||||
const task = mockTasks.find(t => t.id === e.task_id);
|
|
||||||
const existing = taskMap.get(e.task_id) || { id: e.task_id, name: task?.name_en ?? "Unknown", name_es: task?.name_es ?? null, total_hours: 0, entry_count: 0 };
|
|
||||||
existing.total_hours += e.hours;
|
|
||||||
existing.entry_count += 1;
|
|
||||||
taskMap.set(e.task_id, existing);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
by_worker: Array.from(workerMap.values()),
|
|
||||||
by_task: Array.from(taskMap.values()),
|
|
||||||
totals: {
|
|
||||||
entry_count: entries.length,
|
|
||||||
total_hours: entries.reduce((sum, e) => sum + e.hours, 0),
|
|
||||||
open_count: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
|
return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,27 +204,7 @@ export type TimeTrackingSettings = {
|
|||||||
brand_name: string;
|
brand_name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getTimeTrackingSettings(brandId: string): Promise<TimeTrackingSettings | null> {
|
export async function getTimeTrackingSettings(_brandId: string): Promise<TimeTrackingSettings | null> {
|
||||||
if (useMockData) {
|
|
||||||
return {
|
|
||||||
id: "settings-mock",
|
|
||||||
brand_id: brandId,
|
|
||||||
pay_period_start_day: 0,
|
|
||||||
pay_period_length_days: 7,
|
|
||||||
daily_overtime_threshold: 8,
|
|
||||||
weekly_overtime_threshold: 40,
|
|
||||||
overtime_multiplier: 1.5,
|
|
||||||
overtime_notifications: true,
|
|
||||||
notification_emails: ["admin@tuxedocorn.com"],
|
|
||||||
notification_sms_numbers: [],
|
|
||||||
enable_daily_alerts: true,
|
|
||||||
enable_weekly_alerts: true,
|
|
||||||
daily_alert_threshold: 8,
|
|
||||||
weekly_alert_threshold: 40,
|
|
||||||
send_end_of_period_summary: true,
|
|
||||||
brand_name: "Tuxedo Corn",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Real RPC not in SaaS rebuild.
|
// Real RPC not in SaaS rebuild.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,5 @@ export async function wholesaleLogoutAction(): Promise<{ success: true } | { suc
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
// Best-effort cookie cleanup so a returning customer doesn't see stale state
|
// Best-effort cookie cleanup so a returning customer doesn't see stale state
|
||||||
cookieStore.delete("wholesale_session");
|
cookieStore.delete("wholesale_session");
|
||||||
cookieStore.delete("sb-wnzkhezyhnfzhkhiflrp-auth-token");
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getAdminOrderDetail } from "@/actions/orders";
|
import { getAdminOrderDetail, type AdminOrder } from "@/actions/orders";
|
||||||
import OrderEditForm from "@/components/admin/OrderEditForm";
|
import OrderEditForm from "@/components/admin/OrderEditForm";
|
||||||
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
|
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
|
||||||
import OrderPickupAction from "@/components/admin/OrderPickupAction";
|
import OrderPickupAction from "@/components/admin/OrderPickupAction";
|
||||||
@@ -14,6 +14,15 @@ type OrderDetailPageProps = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OrderItem = {
|
||||||
|
id: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number | string;
|
||||||
|
products?: {
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
function formatCurrency(amount: number) {
|
function formatCurrency(amount: number) {
|
||||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||||
}
|
}
|
||||||
@@ -139,7 +148,7 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
|||||||
|
|
||||||
{order.order_items && order.order_items.length > 0 ? (
|
{order.order_items && order.order_items.length > 0 ? (
|
||||||
<div className="mt-4 divide-y divide-stone-100">
|
<div className="mt-4 divide-y divide-stone-100">
|
||||||
{order.order_items.map((item: any) => (
|
{order.order_items.map((item: OrderItem) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center justify-between py-3"
|
className="flex items-center justify-between py-3"
|
||||||
@@ -214,7 +223,7 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
|||||||
<p className="mt-1 text-sm text-stone-500">Update customer details, pricing, and status</p>
|
<p className="mt-1 text-sm text-stone-500">Update customer details, pricing, and status</p>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<OrderEditForm order={order as any} brandId={brandId} />
|
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -53,12 +53,12 @@ export default async function AdminOrdersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { data: prods } = await prodQuery;
|
const { data: prods } = await prodQuery;
|
||||||
brandProducts = (prods ?? []).map((p: any) => ({
|
brandProducts = (prods ?? []).map((p) => ({
|
||||||
id: p.id,
|
id: String(p.id),
|
||||||
name: p.name,
|
name: String(p.name ?? ""),
|
||||||
price: Number(p.price),
|
price: Number(p.price),
|
||||||
type: p.type ?? null,
|
type: p.type as string | null ?? null,
|
||||||
active: p.active ?? true,
|
active: Boolean(p.active ?? true),
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
// non-fatal for the orders list
|
// non-fatal for the orders list
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default async function AdminPage() {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
if (firstBrand?.id) {
|
if (firstBrand?.id) {
|
||||||
dashboardBrandId = firstBrand.id;
|
dashboardBrandId = String(firstBrand.id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[admin/page] supabase brands lookup failed:", err);
|
console.error("[admin/page] supabase brands lookup failed:", err);
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { getAdminOrders } from "@/actions/orders";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Pre-compute cutoff time to avoid impure function in render
|
||||||
|
const PICKUP_WINDOW_MS = 72 * 60 * 60 * 1000;
|
||||||
|
const pickedUpCutoff = new Date(Date.now() - PICKUP_WINDOW_MS);
|
||||||
|
|
||||||
export default async function DriverPickupPage() {
|
export default async function DriverPickupPage() {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
const { orders, stops } = await getAdminOrders();
|
const { orders, stops } = await getAdminOrders();
|
||||||
@@ -26,8 +30,7 @@ export default async function DriverPickupPage() {
|
|||||||
(o) =>
|
(o) =>
|
||||||
o.pickup_complete &&
|
o.pickup_complete &&
|
||||||
o.pickup_completed_at &&
|
o.pickup_completed_at &&
|
||||||
new Date(o.pickup_completed_at) >
|
new Date(o.pickup_completed_at) > pickedUpCutoff
|
||||||
new Date(Date.now() - 72 * 60 * 60 * 1000)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,12 +11,32 @@ type ProductDetailPageProps = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
price: number;
|
||||||
|
active: boolean;
|
||||||
|
type: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
is_taxable: boolean;
|
||||||
|
pickup_type: string | null;
|
||||||
|
brands?: { name: string; slug: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Brand {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
|
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const [{ data: product, error }, { data: brands }] = await Promise.all([
|
const [{ data: product, error }, { data: brands }] = await Promise.all([
|
||||||
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single(),
|
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single() as unknown as { data: Product | null; error: { message: string } | null },
|
||||||
supabase.from("brands").select("id, name, slug"),
|
supabase.from("brands").select("id, name, slug") as unknown as { data: Brand[] | null },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
@@ -109,14 +129,14 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
|
|||||||
product={{
|
product={{
|
||||||
id: product.id,
|
id: product.id,
|
||||||
name: product.name,
|
name: product.name,
|
||||||
description: product.description,
|
description: product.description ?? "",
|
||||||
price: Number(product.price),
|
price: Number(product.price),
|
||||||
type: product.type,
|
type: product.type ?? "pickup",
|
||||||
active: product.active,
|
active: product.active,
|
||||||
brand_id: product.brand_id,
|
brand_id: product.brand_id,
|
||||||
image_url: product.image_url,
|
image_url: product.image_url ?? "",
|
||||||
is_taxable: product.is_taxable,
|
is_taxable: product.is_taxable,
|
||||||
pickup_type: product.pickup_type,
|
pickup_type: product.pickup_type ?? "pickup",
|
||||||
}}
|
}}
|
||||||
brands={brands ?? []}
|
brands={brands ?? []}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { asc, eq, sql } from "drizzle-orm";
|
import { asc, eq, sql } from "drizzle-orm";
|
||||||
import { withPlatformAdmin, withTenant } from "@/db/client";
|
import { withPlatformAdmin, withBrand } from "@/db/client";
|
||||||
import { products, productImages, tenants } from "@/db/schema";
|
import { products, productImages, brands } from "@/db/schema";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
@@ -53,14 +53,14 @@ export default async function AdminProductsPage() {
|
|||||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||||
|
|
||||||
// Platform admins need a brand picker for new products
|
// Platform admins need a brand picker for new products
|
||||||
let brands: { id: string; name: string }[] = [];
|
let brandOptions: { id: string; name: string }[] = [];
|
||||||
if (isPlatformAdmin) {
|
if (isPlatformAdmin) {
|
||||||
const result = await getBrands();
|
const result = await getBrands();
|
||||||
brands = result.brands ?? [];
|
brandOptions = result.brands ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query products + their first image. The new SaaS schema:
|
// Query products + their first image. The new SaaS schema:
|
||||||
// * renamed `brand_id` → `tenant_id` on products
|
// * renamed `brand_id` → `brand_id` on products
|
||||||
// * renamed `price` (numeric) → `price_cents` (integer)
|
// * renamed `price` (numeric) → `price_cents` (integer)
|
||||||
// * moved `image_url` off the products table into a separate
|
// * moved `image_url` off the products table into a separate
|
||||||
// `product_images` table (keyed by `product_id` + `position`)
|
// `product_images` table (keyed by `product_id` + `position`)
|
||||||
@@ -71,7 +71,7 @@ export default async function AdminProductsPage() {
|
|||||||
let productsList: ProductRow[] = [];
|
let productsList: ProductRow[] = [];
|
||||||
let queryError: string | null = null;
|
let queryError: string | null = null;
|
||||||
try {
|
try {
|
||||||
const baseQuery = (db: Parameters<Parameters<typeof withTenant>[1]>[0]) =>
|
const baseQuery = (db: Parameters<Parameters<typeof withBrand>[1]>[0]) =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: products.id,
|
id: products.id,
|
||||||
@@ -79,13 +79,13 @@ export default async function AdminProductsPage() {
|
|||||||
description: products.description,
|
description: products.description,
|
||||||
priceCents: products.priceCents,
|
priceCents: products.priceCents,
|
||||||
active: products.active,
|
active: products.active,
|
||||||
tenantId: products.tenantId,
|
brandId: products.brandId,
|
||||||
tenantName: tenants.name,
|
brandName: brands.name,
|
||||||
firstImageKey: productImages.storageKey,
|
firstImageKey: productImages.storageKey,
|
||||||
firstImagePosition: productImages.position,
|
firstImagePosition: productImages.position,
|
||||||
})
|
})
|
||||||
.from(products)
|
.from(products)
|
||||||
.leftJoin(tenants, eq(tenants.id, products.tenantId))
|
.leftJoin(brands, eq(brands.id, products.brandId))
|
||||||
// Pull only the lowest-position image per product. The
|
// Pull only the lowest-position image per product. The
|
||||||
// `position` index on product_images (product_id, position) makes
|
// `position` index on product_images (product_id, position) makes
|
||||||
// this cheap; a real-world scale would replace it with a
|
// this cheap; a real-world scale would replace it with a
|
||||||
@@ -105,7 +105,7 @@ export default async function AdminProductsPage() {
|
|||||||
const rows = isPlatformAdmin
|
const rows = isPlatformAdmin
|
||||||
? await withPlatformAdmin((db) => baseQuery(db))
|
? await withPlatformAdmin((db) => baseQuery(db))
|
||||||
: brandId
|
: brandId
|
||||||
? await withTenant(brandId, (db) => baseQuery(db))
|
? await withBrand(brandId, (db) => baseQuery(db))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Resolve each row's first image to a public URL. Until object-store
|
// Resolve each row's first image to a public URL. Until object-store
|
||||||
@@ -120,7 +120,7 @@ export default async function AdminProductsPage() {
|
|||||||
type: "pickup", // legacy column not in new schema
|
type: "pickup", // legacy column not in new schema
|
||||||
active: r.active,
|
active: r.active,
|
||||||
image_url: r.firstImageKey, // storage key, not a public URL yet
|
image_url: r.firstImageKey, // storage key, not a public URL yet
|
||||||
brand_id: r.tenantId,
|
brand_id: r.brandId,
|
||||||
is_taxable: false, // legacy column not in new schema
|
is_taxable: false, // legacy column not in new schema
|
||||||
available_from: null,
|
available_from: null,
|
||||||
available_until: null,
|
available_until: null,
|
||||||
@@ -147,7 +147,7 @@ export default async function AdminProductsPage() {
|
|||||||
<ProductsClient
|
<ProductsClient
|
||||||
products={productsList}
|
products={productsList}
|
||||||
brandId={brandId ?? undefined}
|
brandId={brandId ?? undefined}
|
||||||
brands={brands}
|
brands={brandOptions}
|
||||||
isPlatformAdmin={isPlatformAdmin}
|
isPlatformAdmin={isPlatformAdmin}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default async function ReportsPage() {
|
|||||||
const { data: brands } = await supabase
|
const { data: brands } = await supabase
|
||||||
.from("brands")
|
.from("brands")
|
||||||
.select("id, name")
|
.select("id, name")
|
||||||
.order("name");
|
.order("name") as unknown as { data: { id: string; name: string }[] | null };
|
||||||
|
|
||||||
const initialBrandId = isPlatformAdmin
|
const initialBrandId = isPlatformAdmin
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||||
import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots";
|
import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots";
|
||||||
@@ -26,13 +25,9 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) redirect("/login");
|
if (!adminUser) redirect("/login");
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
|
||||||
const isDevMode = !!devSession;
|
|
||||||
|
|
||||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
|
||||||
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
|
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||||
|
|
||||||
const [detailResult, ordersResult] = await Promise.all([
|
const [detailResult, ordersResult] = await Promise.all([
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||||
import { getRouteTraceLots } from "@/actions/route-trace/lots";
|
import { getRouteTraceLots } from "@/actions/route-trace/lots";
|
||||||
@@ -22,13 +21,9 @@ export default async function LotsPage() {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) redirect("/login");
|
if (!adminUser) redirect("/login");
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
|
||||||
const isDevMode = !!devSession;
|
|
||||||
|
|
||||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
|
||||||
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
|
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||||
|
|
||||||
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
|
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||||
import {
|
import {
|
||||||
@@ -41,14 +40,9 @@ export default async function RouteTraceDashboardPage() {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) redirect("/login");
|
if (!adminUser) redirect("/login");
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const devSession = cookieStore.get("dev_session")?.value;
|
|
||||||
const isDevMode = !!devSession;
|
|
||||||
|
|
||||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
|
||||||
// Bypass feature check in dev mode (dev_session cookie present)
|
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||||
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
|
|
||||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||||
|
|
||||||
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
|
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default async function BillingPage({ params }: Props) {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.single();
|
.single();
|
||||||
if (firstBrand?.id) {
|
if (firstBrand?.id) {
|
||||||
resolvedBrandId = firstBrand.id;
|
resolvedBrandId = String(firstBrand.id);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
|
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default async function IntegrationsPage() {
|
|||||||
|
|
||||||
// Platform admins: fetch all brands for the picker
|
// Platform admins: fetch all brands for the picker
|
||||||
const brands = isPlatformAdmin
|
const brands = isPlatformAdmin
|
||||||
? (await supabase.from("brands").select("id, name").order("name")).data ?? []
|
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user