Compare commits

...

10 Commits

Author SHA1 Message Date
Eric Gullickson
9b0de6a5b8 fix: I dunno, I'm making git server changes 2025-12-29 08:44:49 -06:00
Eric Gullickson
57d2c43da7 fix: Email template improvements 2025-12-28 16:56:36 -06:00
Eric Gullickson
e65669fede fix: iOS 26 troubleshooting 1.0 2025-12-27 20:29:25 -06:00
Eric Gullickson
69171f7778 fix: post Dark mode fixes 2025-12-27 20:00:51 -06:00
Eric Gullickson
1799f2fee1 fix: CI/CD v1.6 2025-12-27 17:04:07 -06:00
Eric Gullickson
e0d1cd342e fix: CI/CD take 3 2025-12-27 16:51:53 -06:00
Eric Gullickson
cafaf8cf5d fix: CI/CD changes... again 2025-12-27 16:44:01 -06:00
Eric Gullickson
bf84e64ee9 fix: CI/CD permission fix 2025-12-27 16:38:28 -06:00
Eric Gullickson
dc2c731119 fix: Database schema fixes. CI/CD improvements. 2025-12-27 16:23:22 -06:00
Eric Gullickson
344df5184c fix: Restore backup bug 2025-12-27 13:54:38 -06:00
68 changed files with 245468 additions and 481856 deletions

View File

@@ -1,57 +0,0 @@
name: Build and Push Images to Dockerhub and GHCR
on:
push:
branches: ["main"]
release:
types: ["published"]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
service: [backend, frontend]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: "${{ secrets.DH_USER }}"
password: "${{ secrets.DH_PASS }}"
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: "${{ secrets.GHCR_USER }}"
password: "${{ secrets.GHCR_PAT }}"
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
context: workflow
images: |
ericgullickson/motovaultpro-${{ matrix.service }}
ghcr.io/ericgullickson/motovaultpro-${{ matrix.service }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=tag
labels: |
org.opencontainers.image.title=MotoVaultPro ${{ matrix.service }}
org.opencontainers.image.description=MotoVaultPro ${{ matrix.service }} service
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.service }}
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,30 +1,50 @@
# MotoVaultPro GitLab CI/CD Pipeline # MotoVaultPro GitLab CI/CD Pipeline - Blue-Green Deployment
# GitLab 18.6+ with shell executor # GitLab 18.6+ with separate build and production runners
# See docs/CICD-DEPLOY.md for complete documentation # See docs/CICD-DEPLOY.md for complete documentation
# v1.5 # v2.0 - Blue-Green with Auto-Rollback
stages: stages:
- validate - validate
- build - build
- deploy - deploy-prepare
- deploy-switch
- verify - verify
- rollback
- notify
variables: variables:
# Use stable clone path instead of runner-specific path # Registry configuration
GIT_CLONE_PATH: $CI_BUILDS_DIR/motovaultpro REGISTRY: registry.motovaultpro.com
DEPLOY_PATH: $CI_BUILDS_DIR/motovaultpro REGISTRY_MIRRORS: ${REGISTRY}/mirrors
DOCKER_COMPOSE_FILE: docker-compose.yml IMAGE_TAG: ${CI_COMMIT_SHORT_SHA}
DOCKER_COMPOSE_PROD_FILE: docker-compose.prod.yml BACKEND_IMAGE: ${REGISTRY}/motovaultpro/backend:${IMAGE_TAG}
FRONTEND_IMAGE: ${REGISTRY}/motovaultpro/frontend:${IMAGE_TAG}
# Fix permissions after every job - docker creates files as root # Deployment configuration
GIT_CLONE_PATH: ${CI_BUILDS_DIR}/motovaultpro
DEPLOY_PATH: ${CI_BUILDS_DIR}/motovaultpro
COMPOSE_FILE: docker-compose.yml
COMPOSE_BLUE_GREEN: docker-compose.blue-green.yml
# Health check configuration
HEALTH_CHECK_TIMEOUT: "60"
# Default after_script to fix permissions
default: default:
after_script: after_script:
- echo "Fixing file permissions..." - echo "Fixing file permissions..."
- sudo chown -R gitlab-runner:gitlab-runner "$DEPLOY_PATH" 2>/dev/null || true - sudo chown -R gitlab-runner:gitlab-runner "$DEPLOY_PATH" 2>/dev/null || true
- sudo chown -R 1001:1001 "$DEPLOY_PATH/data/backups" "$DEPLOY_PATH/data/documents" 2>/dev/null || true
# Validate Stage - Check prerequisites # ============================================
# Stage 1: VALIDATE
# Check prerequisites before starting pipeline
# ============================================
validate: validate:
stage: validate stage: validate
tags:
- production
- shell
only: only:
- main - main
script: script:
@@ -32,129 +52,385 @@ validate:
- echo "Validating deployment prerequisites..." - echo "Validating deployment prerequisites..."
- echo "==========================================" - echo "=========================================="
- echo "Checking Docker..." - echo "Checking Docker..."
- 'docker info > /dev/null 2>&1 || (echo "ERROR: Docker not accessible" && exit 1)' - docker info > /dev/null 2>&1 || (echo "ERROR - Docker not accessible" && exit 1)
- echo "OK Docker is accessible" - echo "OK - Docker is accessible"
- echo "Checking Docker Compose..." - echo "Checking Docker Compose..."
- 'docker compose version > /dev/null 2>&1 || (echo "ERROR: Docker Compose not available" && exit 1)' - docker compose version > /dev/null 2>&1 || (echo "ERROR - Docker Compose not available" && exit 1)
- echo "OK Docker Compose is available" - echo "OK - Docker Compose is available"
- echo "Checking deployment path..." - echo "Checking deployment path..."
- 'test -d "$DEPLOY_PATH" || (echo "ERROR: DEPLOY_PATH not found" && exit 1)' - test -d "$DEPLOY_PATH" || (echo "ERROR - DEPLOY_PATH not found" && exit 1)
- echo "OK Deployment path exists" - echo "OK - Deployment path exists"
- echo "Checking registry access..."
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$REGISTRY" || true
- echo "OK - Registry authentication configured"
- echo "Determining target stack..."
- |
STATE_FILE="$DEPLOY_PATH/config/deployment/state.json"
if [ -f "$STATE_FILE" ] && command -v jq &> /dev/null; then
ACTIVE_STACK=$(jq -r '.active_stack // "blue"' "$STATE_FILE")
if [ "$ACTIVE_STACK" = "blue" ]; then
echo "TARGET_STACK=green" >> deploy.env
else
echo "TARGET_STACK=blue" >> deploy.env
fi
else
echo "TARGET_STACK=green" >> deploy.env
fi
cat deploy.env
- echo "==========================================" - echo "=========================================="
- echo "Validation complete" - echo "Validation complete"
- echo "==========================================" - echo "=========================================="
artifacts:
reports:
dotenv: deploy.env
# Build Stage - Build Docker images # ============================================
# Stage 2: BUILD
# Build and push images to GitLab Container Registry
# Runs on dedicated build server (shell executor)
# ============================================
build: build:
stage: build stage: build
tags:
- build
only: only:
- main - main
script: script:
- echo "Authenticating with GitLab Container Registry..."
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$REGISTRY"
- echo "==========================================" - echo "=========================================="
- echo "Building Docker images..." - echo "Building Docker images..."
- echo "Commit - ${CI_COMMIT_SHORT_SHA}"
- echo "Backend - ${BACKEND_IMAGE}"
- echo "Frontend - ${FRONTEND_IMAGE}"
- echo "==========================================" - echo "=========================================="
- cd "$DEPLOY_PATH"
- echo "Building images..." # Build backend
- docker compose -f $DOCKER_COMPOSE_FILE build --no-cache - echo "Building backend..."
- |
docker build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from ${REGISTRY}/motovaultpro/backend:latest \
-t ${BACKEND_IMAGE} \
-t ${REGISTRY}/motovaultpro/backend:latest \
-f backend/Dockerfile \
.
# Build frontend
- echo "Building frontend..."
- |
docker build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--build-arg VITE_AUTH0_DOMAIN=${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com} \
--build-arg VITE_AUTH0_CLIENT_ID=${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3} \
--build-arg VITE_AUTH0_AUDIENCE=${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com} \
--build-arg VITE_API_BASE_URL=/api \
--cache-from ${REGISTRY}/motovaultpro/frontend:latest \
-t ${FRONTEND_IMAGE} \
-t ${REGISTRY}/motovaultpro/frontend:latest \
-f frontend/Dockerfile \
frontend
# Push images
- echo "Pushing images to registry..."
- docker push ${BACKEND_IMAGE}
- docker push ${FRONTEND_IMAGE}
- docker push ${REGISTRY}/motovaultpro/backend:latest
- docker push ${REGISTRY}/motovaultpro/frontend:latest
- echo "==========================================" - echo "=========================================="
- echo "Build complete" - echo "Build complete"
- echo "==========================================" - echo "=========================================="
# Deploy Stage - Inject secrets and deploy services # ============================================
deploy: # Stage 3: DEPLOY-PREPARE
stage: deploy # Pull images, start inactive stack, run health checks
# ============================================
deploy-prepare:
stage: deploy-prepare
tags:
- production
- shell
only: only:
- main - main
needs:
- job: validate
artifacts: true
- job: build
environment: environment:
name: production name: production
url: https://motovaultpro.com url: https://motovaultpro.com
script: script:
- echo "==========================================" - echo "=========================================="
- echo "Deploying MotoVaultPro..." - echo "Preparing deployment to ${TARGET_STACK} stack..."
- echo "==========================================" - echo "=========================================="
- cd "$DEPLOY_PATH" - cd "$DEPLOY_PATH"
- echo "Step 1/7 Injecting secrets..."
# Authenticate with registry
- echo "Authenticating with registry..."
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$REGISTRY"
# Inject secrets
- echo "Step 1/5 - Injecting secrets..."
- chmod +x scripts/inject-secrets.sh - chmod +x scripts/inject-secrets.sh
- ./scripts/inject-secrets.sh - ./scripts/inject-secrets.sh
- echo "Step 2/7 Stopping existing services..."
- docker compose -f $DOCKER_COMPOSE_FILE -f $DOCKER_COMPOSE_PROD_FILE down --timeout 30 || true # Initialize data directories
- echo "Step 3/7 Pulling base images..." - echo "Step 2/5 - Initializing data directories..."
- docker compose -f $DOCKER_COMPOSE_FILE pull - sudo mkdir -p data/backups data/documents
- echo "Step 4/7 Starting database services..." - sudo chown -R 1001:1001 data/backups data/documents
- docker compose -f $DOCKER_COMPOSE_FILE -f $DOCKER_COMPOSE_PROD_FILE up -d mvp-postgres mvp-redis - sudo chmod 755 data/backups data/documents
- echo "Waiting for database to be ready..."
- sleep 15 # Pull new images
- echo "Step 5/7 Running database migrations..." - echo "Step 3/5 - Pulling images..."
- docker compose -f $DOCKER_COMPOSE_FILE run --rm mvp-backend npm run migrate || echo "Migration skipped" - docker pull ${BACKEND_IMAGE}
- echo "Step 6/7 Running vehicle ETL import..." - docker pull ${FRONTEND_IMAGE}
# Start inactive stack
- echo "Step 4/5 - Starting ${TARGET_STACK} stack..."
- | - |
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < data/vehicle-etl/migrations/001_create_vehicle_database.sql export BACKEND_IMAGE=${BACKEND_IMAGE}
docker exec -i mvp-postgres psql -U postgres -d motovaultpro -c "TRUNCATE TABLE vehicle_options RESTART IDENTITY CASCADE; TRUNCATE TABLE engines RESTART IDENTITY CASCADE; TRUNCATE TABLE transmissions RESTART IDENTITY CASCADE;" export FRONTEND_IMAGE=${FRONTEND_IMAGE}
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < data/vehicle-etl/output/01_engines.sql docker compose -f $COMPOSE_FILE -f $COMPOSE_BLUE_GREEN up -d \
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < data/vehicle-etl/output/02_transmissions.sql mvp-frontend-${TARGET_STACK} mvp-backend-${TARGET_STACK}
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < data/vehicle-etl/output/03_vehicle_options.sql
- echo "Flushing Redis cache..." # Wait for stack to be ready
- docker exec mvp-redis redis-cli FLUSHALL - echo "Step 5/5 - Waiting for stack health..."
- echo "Vehicle ETL import completed" - sleep 10
- echo "Step 7/7 Starting all services..."
- docker compose -f $DOCKER_COMPOSE_FILE -f $DOCKER_COMPOSE_PROD_FILE up -d # Run health check
- echo "Waiting for services to initialize..." - echo "Running health check on ${TARGET_STACK} stack..."
- sleep 30 - chmod +x scripts/ci/health-check.sh
- ./scripts/ci/health-check.sh ${TARGET_STACK} ${HEALTH_CHECK_TIMEOUT}
# Update state with deployment info
- |
STATE_FILE="config/deployment/state.json"
if [ -f "$STATE_FILE" ] && command -v jq &> /dev/null; then
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
jq --arg stack "$TARGET_STACK" \
--arg commit "$CI_COMMIT_SHORT_SHA" \
--arg ts "$TIMESTAMP" \
'.[$stack].version = $commit | .[$stack].commit = $commit | .[$stack].deployed_at = $ts | .[$stack].healthy = true' \
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
fi
- echo "==========================================" - echo "=========================================="
- echo "Deployment complete" - echo "Deploy preparation complete"
- echo "==========================================" - echo "=========================================="
# Verify Stage - Health checks # ============================================
verify: # Stage 4: DEPLOY-SWITCH
stage: verify # Switch traffic to new stack
# ============================================
deploy-switch:
stage: deploy-switch
tags:
- production
- shell
only: only:
- main - main
needs:
- job: validate
artifacts: true
- job: deploy-prepare
script: script:
- echo "==========================================" - echo "=========================================="
- echo "Verifying deployment..." - echo "Switching traffic to ${TARGET_STACK} stack..."
- echo "==========================================" - echo "=========================================="
- cd "$DEPLOY_PATH" - cd "$DEPLOY_PATH"
- echo "Checking container status..."
# Switch traffic
- chmod +x scripts/ci/switch-traffic.sh
- ./scripts/ci/switch-traffic.sh ${TARGET_STACK} instant
# Update state
- | - |
FAILED=0 STATE_FILE="config/deployment/state.json"
for service in mvp-traefik mvp-frontend mvp-backend mvp-postgres mvp-redis; do if [ -f "$STATE_FILE" ] && command -v jq &> /dev/null; then
status=$(docker inspect --format='{{.State.Status}}' $service 2>/dev/null || echo "not found") TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
if [ "$status" != "running" ]; then jq --arg commit "$CI_COMMIT_SHORT_SHA" \
echo "ERROR: $service is not running (status: $status)" --arg ts "$TIMESTAMP" \
docker logs $service --tail 50 2>/dev/null || true '.last_deployment = $ts | .last_deployment_commit = $commit | .last_deployment_status = "success" | .rollback_available = true' \
FAILED=1 "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
else
echo "OK: $service is running"
fi
done
if [ $FAILED -eq 1 ]; then
echo "One or more services failed to start"
exit 1
fi fi
- echo "Checking backend health..."
- echo "=========================================="
- echo "Traffic switch complete"
- echo "=========================================="
# ============================================
# Stage 5: VERIFY
# Production health verification after switch
# ============================================
verify:
stage: verify
tags:
- production
- shell
only:
- main
needs:
- job: validate
artifacts: true
- job: deploy-switch
script:
- echo "=========================================="
- echo "Verifying production deployment..."
- echo "=========================================="
- cd "$DEPLOY_PATH"
# Wait for Traefik to propagate routing
- echo "Waiting for traffic routing to stabilize..."
- sleep 5
# Verify via external endpoint
- echo "Checking external endpoint..."
- | - |
HEALTH_OK=0
for i in 1 2 3 4 5 6; do for i in 1 2 3 4 5 6; do
if docker exec mvp-backend curl -sf http://localhost:3001/health > /dev/null 2>&1; then if curl -sf https://motovaultpro.com/api/health > /dev/null 2>&1; then
echo "OK: Backend health check passed" echo "OK - External health check passed"
HEALTH_OK=1
break break
fi fi
echo "Attempt $i/6: Backend not ready, waiting 10s..." if [ $i -eq 6 ]; then
echo "ERROR - External health check failed after 6 attempts"
exit 1
fi
echo "Attempt $i/6 - Waiting 10s..."
sleep 10 sleep 10
done done
if [ $HEALTH_OK -eq 0 ]; then
echo "ERROR: Backend health check failed after 6 attempts" # Verify container status
docker logs mvp-backend --tail 100 - echo "Checking container status..."
exit 1
fi
- echo "Checking frontend..."
- | - |
if docker compose -f $DOCKER_COMPOSE_FILE exec -T mvp-frontend curl -sf http://localhost:3000 > /dev/null 2>&1; then for service in mvp-frontend-${TARGET_STACK} mvp-backend-${TARGET_STACK}; do
echo "OK: Frontend is accessible" status=$(docker inspect --format='{{.State.Status}}' $service 2>/dev/null || echo "not found")
else health=$(docker inspect --format='{{.State.Health.Status}}' $service 2>/dev/null || echo "unknown")
echo "WARNING: Frontend check failed (might need Traefik routing)" if [ "$status" != "running" ] || [ "$health" != "healthy" ]; then
fi echo "ERROR - $service is not healthy (status: $status, health: $health)"
docker logs $service --tail 50 2>/dev/null || true
exit 1
fi
echo "OK - $service is running and healthy"
done
- echo "==========================================" - echo "=========================================="
- echo "Deployment verified successfully!" - echo "Deployment verified successfully!"
- echo "Version ${CI_COMMIT_SHORT_SHA} is now live"
- echo "==========================================" - echo "=========================================="
# ============================================
# Stage 6: ROLLBACK (on failure)
# Automatic rollback if verify stage fails
# ============================================
rollback:
stage: rollback
tags:
- production
- shell
only:
- main
when: on_failure
needs:
- job: validate
artifacts: true
- job: deploy-switch
- job: verify
script:
- echo "=========================================="
- echo "INITIATING AUTO-ROLLBACK"
- echo "=========================================="
- cd "$DEPLOY_PATH"
# Run rollback script
- chmod +x scripts/ci/auto-rollback.sh
- ./scripts/ci/auto-rollback.sh "Verify stage failed - automatic rollback"
# Update state
- |
STATE_FILE="config/deployment/state.json"
if [ -f "$STATE_FILE" ] && command -v jq &> /dev/null; then
jq '.last_deployment_status = "rolled_back"' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
fi
- echo "=========================================="
- echo "Rollback complete"
- echo "=========================================="
# ============================================
# Stage 7: NOTIFY
# Send deployment notifications
# ============================================
notify-success:
stage: notify
tags:
- production
- shell
only:
- main
needs:
- job: verify
script:
- echo "Sending success notification..."
- cd "$DEPLOY_PATH"
- chmod +x scripts/ci/notify.sh
- ./scripts/ci/notify.sh success "Version ${CI_COMMIT_SHORT_SHA} deployed successfully" ${CI_COMMIT_SHORT_SHA}
notify-failure:
stage: notify
tags:
- production
- shell
only:
- main
when: on_failure
needs:
- job: build
optional: true
- job: deploy-prepare
optional: true
- job: deploy-switch
optional: true
- job: verify
optional: true
script:
- echo "Sending failure notification..."
- cd "$DEPLOY_PATH"
- chmod +x scripts/ci/notify.sh
- ./scripts/ci/notify.sh failure "Deployment of ${CI_COMMIT_SHORT_SHA} failed" ${CI_COMMIT_SHORT_SHA}
# ============================================
# Manual Jobs
# ============================================
# Manual maintenance migration job
maintenance-migration:
stage: deploy-prepare
tags:
- production
- shell
only:
- main
when: manual
script:
- echo "=========================================="
- echo "MAINTENANCE MODE MIGRATION"
- echo "=========================================="
- cd "$DEPLOY_PATH"
- chmod +x scripts/ci/maintenance-migrate.sh
- ./scripts/ci/maintenance-migrate.sh backup
# Mirror base images (scheduled or manual)
mirror-images:
stage: build
tags:
- build
only:
- schedules
- web
when: manual
script:
- echo "Mirroring base images to GitLab Container Registry..."
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$REGISTRY"
- chmod +x scripts/ci/mirror-base-images.sh
- REGISTRY=${REGISTRY}/mirrors ./scripts/ci/mirror-base-images.sh

View File

@@ -1,7 +1,11 @@
# Production Dockerfile for MotoVaultPro Backend # Production Dockerfile for MotoVaultPro Backend
# Uses mirrored base images from GitLab Container Registry
# Build argument for registry (defaults to GitLab mirrors, falls back to Docker Hub)
ARG REGISTRY_MIRRORS=registry.motovaultpro.com/mirrors
# Stage 1: Build stage # Stage 1: Build stage
FROM node:lts-alpine AS builder FROM ${REGISTRY_MIRRORS}/node:20-alpine AS builder
# Install build dependencies # Install build dependencies
RUN apk add --no-cache dumb-init git curl RUN apk add --no-cache dumb-init git curl
@@ -9,20 +13,25 @@ RUN apk add --no-cache dumb-init git curl
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files from backend directory
COPY package*.json ./ COPY backend/package*.json ./
# Install all dependencies (including dev for building) # Install all dependencies (including dev for building)
RUN npm install && npm cache clean --force RUN npm install && npm cache clean --force
# Copy source code # Copy logo from frontend for email templates (needed for build)
COPY . . RUN mkdir -p frontend/public/images/logos
COPY frontend/public/images/logos/motovaultpro-logo-title.png frontend/public/images/logos/
# Build the application # Copy backend source code
COPY backend/ .
# Build the application (prebuild will encode logo)
ENV DOCKER_BUILD=true
RUN npm run build RUN npm run build
# Stage 2: Production runtime # Stage 2: Production runtime
FROM node:lts-alpine AS production FROM ${REGISTRY_MIRRORS}/node:20-alpine AS production
# Install runtime dependencies only (postgresql-client for backup/restore) # Install runtime dependencies only (postgresql-client for backup/restore)
RUN apk add --no-cache dumb-init curl postgresql-client RUN apk add --no-cache dumb-init curl postgresql-client
@@ -31,7 +40,7 @@ RUN apk add --no-cache dumb-init curl postgresql-client
WORKDIR /app WORKDIR /app
# Copy package files and any lock file generated in builder stage # Copy package files and any lock file generated in builder stage
COPY package*.json ./ COPY backend/package*.json ./
COPY --from=builder /app/package-lock.json ./ COPY --from=builder /app/package-lock.json ./
# Install only production dependencies # Install only production dependencies
@@ -51,6 +60,10 @@ RUN mkdir -p /app/migrations/features /app/migrations/core
COPY --from=builder /app/src/features /app/migrations/features COPY --from=builder /app/src/features /app/migrations/features
COPY --from=builder /app/src/core /app/migrations/core COPY --from=builder /app/src/core /app/migrations/core
# Copy entrypoint script for permission checks
COPY backend/scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh
# Change ownership to non-root user # Change ownership to non-root user
RUN chown -R nodejs:nodejs /app RUN chown -R nodejs:nodejs /app
@@ -64,8 +77,8 @@ EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" CMD node -e "require('http').get('http://localhost:3001/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
# Use dumb-init for proper signal handling # Use dumb-init with entrypoint for permission checks
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"]
# Run production application with auto-migrate (idempotent) # Run production application with auto-migrate (idempotent)
CMD ["sh", "-lc", "node dist/_system/migrations/run-all.js && npm start"] CMD ["sh", "-lc", "node dist/_system/migrations/run-all.js && npm start"]

View File

@@ -0,0 +1,32 @@
#!/bin/sh
# docker-entrypoint.sh
# Ensures data directories have correct permissions on container startup
set -e
echo "Checking data directory permissions..."
# Directories that need to be writable by nodejs user (UID 1001)
DATA_DIRS="/app/data/backups /app/data/documents"
for dir in $DATA_DIRS; do
if [ ! -d "$dir" ]; then
echo "Creating directory: $dir"
mkdir -p "$dir"
fi
# Check if we can write to the directory
if ! touch "$dir/.write-test" 2>/dev/null; then
echo "WARNING: Cannot write to $dir"
echo "This may cause backup/document operations to fail"
echo "Fix: Run 'sudo chown -R 1001:1001 ./data' on the host"
else
rm "$dir/.write-test"
fi
done
echo "Permission checks complete"
echo "Starting application..."
# Execute the CMD from Dockerfile
exec "$@"

View File

@@ -59,7 +59,7 @@ export class CatalogImportService {
async previewImport(csvContent: string): Promise<ImportPreviewResult> { async previewImport(csvContent: string): Promise<ImportPreviewResult> {
const previewId = uuidv4(); const previewId = uuidv4();
const toCreate: ImportRow[] = []; const toCreate: ImportRow[] = [];
const toUpdate: ImportRow[] = []; const toUpdate: ImportRow[] = []; // Kept for interface compatibility (will be empty)
const errors: ImportError[] = []; const errors: ImportError[] = [];
const lines = csvContent.trim().split('\n'); const lines = csvContent.trim().split('\n');
@@ -146,21 +146,8 @@ export class CatalogImportService {
transmissionType, transmissionType,
}; };
// Check if record exists to determine create vs update (upsert logic) // All rows will be inserted with ON CONFLICT handling (proper upsert)
const existsResult = await this.pool.query( toCreate.push(row);
`SELECT id FROM vehicle_options
WHERE year = $1 AND make = $2 AND model = $3 AND trim = $4
LIMIT 1`,
[year, make, model, trim]
);
const exists = (existsResult.rowCount || 0) > 0;
// Auto-detect: if exists -> update, else -> create
if (exists) {
toUpdate.push(row);
} else {
toCreate.push(row);
}
} catch (error: any) { } catch (error: any) {
errors.push({ row: rowNum, error: error.message || 'Parse error' }); errors.push({ row: rowNum, error: error.message || 'Parse error' });
} }
@@ -239,61 +226,29 @@ export class CatalogImportService {
transmissionId = transResult.rows[0].id; transmissionId = transResult.rows[0].id;
} }
// Insert vehicle option // Upsert vehicle option (insert or update if exists)
await client.query( const upsertResult = await client.query(
`INSERT INTO vehicle_options (year, make, model, trim, engine_id, transmission_id) `INSERT INTO vehicle_options (year, make, model, trim, engine_id, transmission_id)
VALUES ($1, $2, $3, $4, $5, $6)`, VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (year, make, model, trim, engine_id, transmission_id)
DO UPDATE SET updated_at = NOW()
RETURNING (xmax = 0) AS inserted`,
[row.year, row.make, row.model, row.trim, engineId, transmissionId] [row.year, row.make, row.model, row.trim, engineId, transmissionId]
); );
result.created++; // Check if this was an insert (xmax=0) or update (xmax!=0)
const wasInserted = upsertResult.rows[0].inserted;
if (wasInserted) {
result.created++;
} else {
result.updated++;
}
} catch (error: any) { } catch (error: any) {
result.errors.push({ row: 0, error: `Failed to create ${row.year} ${row.make} ${row.model} ${row.trim}: ${error.message}` }); result.errors.push({ row: 0, error: `Failed to upsert ${row.year} ${row.make} ${row.model} ${row.trim}: ${error.message}` });
} }
} }
// Process updates // Note: Separate "Process updates" loop removed - ON CONFLICT handles both INSERT and UPDATE
for (const row of preview.toUpdate) {
try {
// Get or create engine
let engineId: number | null = null;
if (row.engineName) {
const engineResult = await client.query(
`INSERT INTO engines (name, fuel_type)
VALUES ($1, 'Gas')
ON CONFLICT ((lower(name))) DO UPDATE SET name = EXCLUDED.name
RETURNING id`,
[row.engineName]
);
engineId = engineResult.rows[0].id;
}
// Get or create transmission
let transmissionId: number | null = null;
if (row.transmissionType) {
const transResult = await client.query(
`INSERT INTO transmissions (type)
VALUES ($1)
ON CONFLICT ((lower(type))) DO UPDATE SET type = EXCLUDED.type
RETURNING id`,
[row.transmissionType]
);
transmissionId = transResult.rows[0].id;
}
// Update vehicle option
await client.query(
`UPDATE vehicle_options
SET engine_id = $5, transmission_id = $6, updated_at = NOW()
WHERE year = $1 AND make = $2 AND model = $3 AND trim = $4`,
[row.year, row.make, row.model, row.trim, engineId, transmissionId]
);
result.updated++;
} catch (error: any) {
result.errors.push({ row: 0, error: `Failed to update ${row.year} ${row.make} ${row.model} ${row.trim}: ${error.message}` });
}
}
await client.query('COMMIT'); await client.query('COMMIT');
@@ -306,13 +261,23 @@ export class CatalogImportService {
logger.debug('Vehicle data cache invalidated after import'); logger.debug('Vehicle data cache invalidated after import');
} }
logger.info('Catalog import completed', { // Log completion with appropriate level
previewId, if (result.errors.length > 0) {
created: result.created, logger.warn('Catalog import completed with errors', {
updated: result.updated, previewId,
errors: result.errors.length, created: result.created,
changedBy, updated: result.updated,
}); errors: result.errors.length,
changedBy,
});
} else {
logger.info('Catalog import completed successfully', {
previewId,
created: result.created,
updated: result.updated,
changedBy,
});
}
return result; return result;
} catch (error) { } catch (error) {

View File

@@ -5,6 +5,7 @@
import { FastifyRequest, FastifyReply } from 'fastify'; import { FastifyRequest, FastifyReply } from 'fastify';
import { Pool } from 'pg'; import { Pool } from 'pg';
import { logger } from '../../../core/logging/logger';
import { BackupService } from '../domain/backup.service'; import { BackupService } from '../domain/backup.service';
import { BackupRestoreService } from '../domain/backup-restore.service'; import { BackupRestoreService } from '../domain/backup-restore.service';
import { import {
@@ -179,8 +180,14 @@ export class BackupController {
const preview = await this.restoreService.previewRestore(request.params.id); const preview = await this.restoreService.previewRestore(request.params.id);
reply.send(preview); reply.send(preview);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to preview restore';
logger.error('Preview restore failed', {
backupId: request.params.id,
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
});
reply.status(400).send({ reply.status(400).send({
error: error instanceof Error ? error.message : 'Failed to preview restore', error: errorMessage,
}); });
} }
} }
@@ -192,7 +199,7 @@ export class BackupController {
try { try {
const result = await this.restoreService.executeRestore({ const result = await this.restoreService.executeRestore({
backupId: request.params.id, backupId: request.params.id,
createSafetyBackup: request.body.createSafetyBackup, createSafetyBackup: request.body?.createSafetyBackup ?? true,
}); });
if (result.success) { if (result.success) {
@@ -211,9 +218,15 @@ export class BackupController {
}); });
} }
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to execute restore';
logger.error('Restore execution failed', {
backupId: request.params.id,
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
});
reply.status(400).send({ reply.status(400).send({
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to execute restore', error: errorMessage,
}); });
} }
} }

View File

@@ -0,0 +1,85 @@
/**
* Base HTML Email Layout
* @ai-summary Main email wrapper with MotoVaultPro branding
* @ai-context Uses table-based layout for email client compatibility
*/
import { EMAIL_STYLES } from './email-styles';
// External logo URL - hosted on GitHub for reliability
const LOGO_URL = 'https://raw.githubusercontent.com/ericgullickson/images/c58b0e4773e8395b532f97f6ab529e38ea4dc8be/motovaultpro-auth0-small.png';
/**
* Renders the complete HTML email layout with branding
* @param content - The rendered email body content (HTML)
* @returns Complete HTML email string with DOCTYPE and layout
*/
export function renderEmailLayout(content: string): string {
return `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>MotoVaultPro</title>
<!--[if mso]>
<style type="text/css">
table { border-collapse: collapse; }
.outlook-fix { font-family: Arial, sans-serif; }
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #f8f9fa;">
<!-- Wrapper table for full width background -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="${EMAIL_STYLES.wrapper}">
<tr>
<td align="center" style="${EMAIL_STYLES.cell}">
<!-- Main container table (max-width 600px) -->
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="${EMAIL_STYLES.container}" class="outlook-fix">
<!-- Header with logo -->
<tr>
<td style="${EMAIL_STYLES.header}">
<img src="${LOGO_URL}" alt="MotoVaultPro" style="${EMAIL_STYLES.logo}" width="280" />
</td>
</tr>
<!-- Content area -->
<tr>
<td style="${EMAIL_STYLES.content}">
${content}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="${EMAIL_STYLES.footer}">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center">
<p style="${EMAIL_STYLES.footerText}">
<strong>Professional Vehicle Management &amp; Maintenance Tracking</strong>
</p>
<p style="${EMAIL_STYLES.footerText}">
<a href="https://motovaultpro.com" style="${EMAIL_STYLES.footerLink}" target="_blank">Login to MotoVaultPro</a>
</p>
<p style="${EMAIL_STYLES.footerText}">
<a href="{{unsubscribeUrl}}" style="${EMAIL_STYLES.footerLink}" target="_blank">Manage Email Preferences</a>
</p>
<p style="${EMAIL_STYLES.copyright}">
&copy; 2025 MotoVaultPro. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}

View File

@@ -0,0 +1,39 @@
/**
* Email Template Inline Styles
* @ai-summary Reusable inline CSS constants for email templates
* @ai-context Email clients require inline styles for proper rendering
*/
export const EMAIL_STYLES = {
// Layout containers
wrapper: 'width: 100%; background-color: #f8f9fa; padding: 20px 0;',
container: 'max-width: 600px; margin: 0 auto; background-color: #ffffff;',
innerContainer: 'width: 100%;',
// Header
header: 'background-color: #7A212A; padding: 30px 20px; text-align: center;',
logo: 'max-width: 280px; height: auto; display: block; margin: 0 auto;',
// Content area
content: 'padding: 40px 30px; font-family: Arial, Helvetica, sans-serif; color: #1e293b; line-height: 1.6; font-size: 16px;',
// Typography
heading: 'color: #7A212A; font-size: 24px; font-weight: bold; margin: 0 0 20px 0; font-family: Arial, Helvetica, sans-serif;',
subheading: 'color: #1e293b; font-size: 18px; font-weight: bold; margin: 0 0 16px 0; font-family: Arial, Helvetica, sans-serif;',
paragraph: 'margin: 0 0 16px 0; font-size: 16px; color: #1e293b; font-family: Arial, Helvetica, sans-serif; line-height: 1.6;',
strong: 'font-weight: bold; color: #7A212A;',
// Footer
footer: 'background-color: #f1f5f9; padding: 30px 20px; text-align: center; border-top: 2px solid #7A212A;',
footerText: 'font-size: 14px; color: #64748b; margin: 8px 0; font-family: Arial, Helvetica, sans-serif;',
footerLink: 'color: #7A212A; text-decoration: none; font-weight: bold;',
footerLinkHover: 'color: #9c2a36; text-decoration: underline;',
copyright: 'font-size: 12px; color: #94a3b8; margin: 16px 0 0 0; font-family: Arial, Helvetica, sans-serif;',
// Divider
divider: 'border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;',
// Table cells
cell: 'padding: 0;',
cellWithPadding: 'padding: 20px;',
} as const;

View File

@@ -23,7 +23,7 @@ export class EmailService {
* Send an email using Resend * Send an email using Resend
* @param to Recipient email address * @param to Recipient email address
* @param subject Email subject line * @param subject Email subject line
* @param html Email body (HTML format) * @param html Email body (HTML format with inline styles for email client compatibility)
* @returns Promise that resolves when email is sent * @returns Promise that resolves when email is sent
*/ */
async send(to: string, subject: string, html: string): Promise<void> { async send(to: string, subject: string, html: string): Promise<void> {
@@ -39,16 +39,4 @@ export class EmailService {
throw new Error(`Failed to send email: ${errorMessage}`); throw new Error(`Failed to send email: ${errorMessage}`);
} }
} }
/**
* Send an email with plain text body (converted to HTML)
* @param to Recipient email address
* @param subject Email subject line
* @param text Email body (plain text)
*/
async sendText(to: string, subject: string, text: string): Promise<void> {
// Convert plain text to HTML with proper line breaks
const html = text.split('\n').map(line => `<p>${line}</p>`).join('');
await this.send(to, subject, html);
}
} }

View File

@@ -94,10 +94,15 @@ export class NotificationsService {
subject: string, subject: string,
body: string, body: string,
variables: Record<string, string | number | boolean | null | undefined> variables: Record<string, string | number | boolean | null | undefined>
): Promise<{ subject: string; body: string }> { ): Promise<{ subject: string; body: string; html: string }> {
const renderedSubject = this.templateService.render(subject, variables);
const renderedBody = this.templateService.render(body, variables);
const renderedHtml = this.templateService.renderEmailHtml(body, variables);
return { return {
subject: this.templateService.render(subject, variables), subject: renderedSubject,
body: this.templateService.render(body, variables), body: renderedBody,
html: renderedHtml,
}; };
} }
@@ -130,10 +135,10 @@ export class NotificationsService {
}; };
const subject = this.templateService.render(template.subject, variables); const subject = this.templateService.render(template.subject, variables);
const body = this.templateService.render(template.body, variables); const htmlBody = this.templateService.renderEmailHtml(template.body, variables);
try { try {
await this.emailService.sendText(userEmail, subject, body); await this.emailService.send(userEmail, subject, htmlBody);
await this.repository.insertNotificationLog({ await this.repository.insertNotificationLog({
user_id: userId, user_id: userId,
@@ -188,10 +193,10 @@ export class NotificationsService {
}; };
const subject = this.templateService.render(template.subject, variables); const subject = this.templateService.render(template.subject, variables);
const body = this.templateService.render(template.body, variables); const htmlBody = this.templateService.renderEmailHtml(template.body, variables);
try { try {
await this.emailService.sendText(userEmail, subject, body); await this.emailService.send(userEmail, subject, htmlBody);
await this.repository.insertNotificationLog({ await this.repository.insertNotificationLog({
user_id: userId, user_id: userId,
@@ -249,9 +254,10 @@ export class NotificationsService {
const subject = this.templateService.render(template.subject, sampleVariables); const subject = this.templateService.render(template.subject, sampleVariables);
const body = this.templateService.render(template.body, sampleVariables); const body = this.templateService.render(template.body, sampleVariables);
const htmlBody = this.templateService.renderEmailHtml(template.body, sampleVariables);
try { try {
await this.emailService.sendText(recipientEmail, `[TEST] ${subject}`, body); await this.emailService.send(recipientEmail, `[TEST] ${subject}`, htmlBody);
return { return {
subject, subject,

View File

@@ -3,6 +3,9 @@
* @ai-context Replaces {{variableName}} with values * @ai-context Replaces {{variableName}} with values
*/ */
import { renderEmailLayout } from './email-layout/base-layout';
import { EMAIL_STYLES } from './email-layout/email-styles';
export class TemplateService { export class TemplateService {
/** /**
* Render a template string by replacing {{variableName}} with values * Render a template string by replacing {{variableName}} with values
@@ -22,6 +25,38 @@ export class TemplateService {
return result; return result;
} }
/**
* Render a template as HTML email with branded layout
* @param template Template string with {{variable}} placeholders
* @param variables Object mapping variable names to values
* @returns Complete HTML email string with layout wrapper
*/
renderEmailHtml(template: string, variables: Record<string, string | number | boolean | null | undefined>): string {
// 1. Replace variables in template body
const renderedContent = this.render(template, variables);
// 2. Escape HTML special characters to prevent XSS
const escapeHtml = (text: string): string => {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
// 3. Convert plain text line breaks to HTML paragraphs
const htmlContent = renderedContent
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.map(line => `<p style="${EMAIL_STYLES.paragraph}">${escapeHtml(line)}</p>`)
.join('\n');
// 4. Wrap in branded email layout
return renderEmailLayout(htmlContent);
}
/** /**
* Extract variable names from a template string * Extract variable names from a template string
* @param template Template string with {{variable}} placeholders * @param template Template string with {{variable}} placeholders

View File

@@ -0,0 +1,16 @@
/**
* Migration: Add HTML body column to email templates
* @ai-summary Non-breaking migration for future HTML template support
* @ai-context Existing plain text templates auto-convert to HTML
*/
-- Add optional html_body column for custom HTML templates (future enhancement)
ALTER TABLE email_templates
ADD COLUMN html_body TEXT DEFAULT NULL;
-- Add comment explaining the column purpose
COMMENT ON COLUMN email_templates.html_body IS
'Optional custom HTML body. If NULL, the plain text body will be auto-converted to HTML with base layout.';
-- No data updates needed - existing templates continue to work
-- The system will auto-convert plain text body to HTML using renderEmailHtml()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
INSERT INTO public.transmissions VALUES (3393, '8-Speed Dual-Clutch', NULL, NULL, '2025-12-27 20:24:19.358069', '2025-12-27 20:24:19.358069');
INSERT INTO public.transmissions VALUES (11, 'Continuously Variable Transmission', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (3404, 'Single-Speed Direct Drive', NULL, NULL, '2025-12-27 20:24:19.358069', '2025-12-27 20:24:19.358069');
INSERT INTO public.transmissions VALUES (15, '5-Speed Automatic Overdrive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (3413, '2-Speed Direct Drive', NULL, NULL, '2025-12-27 20:24:19.358069', '2025-12-27 20:24:19.358069');
INSERT INTO public.transmissions VALUES (32, '4-Speed CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (3072, 'Single-Speed Transmission', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (24, '5-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (9, '4-Speed Automatic Overdrive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (5304, 'ISR Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (5081, 'Electric', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (10, '5-Speed Manual Overdrive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (36, '10-Speed Automatic Transmission', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (13, '6-Speed Manual Overdrive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (22, '1-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (18, '6-Speed CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (29, '8-Speed CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (4, '5-Speed Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (5, '4-Speed Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (3, '3-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (6, '3-Speed Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (35, '2-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (1184, '9-Speed DCT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (23, '7-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (33, '10-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (34, '10-Speed CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (1159, '8-Speed DCT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (1172, '7-Speed DCT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (17, '6-Speed Automatic Overdrive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (7, '4-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (14, '1-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (25, '7-Speed CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (30, '9-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (12, '5-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (20, '6-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (19, '7-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (28, '8-Speed Dual Clutch', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (26, '7-Speed Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (8, '6-Speed Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (115, 'CVT', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (2, 'Manual', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (1, 'Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (27, '9-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (21, '8-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (119, '1-Speed Direct Drive', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (16, '6-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');
INSERT INTO public.transmissions VALUES (31, '10-Speed Automatic', NULL, NULL, '2025-12-27 17:00:28.222415', '2025-12-27 17:00:28.222415');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
/**
* @ai-summary Vehicle catalog data seeding service
* @ai-context Loads vehicle catalog data from exported SQL files after migrations
*/
import { Pool } from 'pg';
import { logger } from '../../../core/logging/logger';
import * as fs from 'fs';
import * as path from 'path';
export class CatalogSeedService {
private readonly dataDir = '/app/migrations/features/platform/data';
constructor(private pool: Pool) {}
/**
* Seed vehicle catalog data if tables are empty
*/
async seedIfEmpty(): Promise<void> {
try {
// Check if data already exists
const count = await this.pool.query('SELECT COUNT(*) FROM vehicle_options');
const rowCount = parseInt(count.rows[0].count, 10);
if (rowCount > 0) {
logger.info('Vehicle catalog already seeded, skipping', { rowCount });
return;
}
logger.info('Seeding vehicle catalog data...');
// Load data files in order
await this.loadDataFile('engines.sql');
await this.loadDataFile('transmissions.sql');
await this.loadDataFile('vehicle_options.sql');
// Verify data loaded
const finalCount = await this.pool.query('SELECT COUNT(*) FROM vehicle_options');
const finalRowCount = parseInt(finalCount.rows[0].count, 10);
logger.info('Vehicle catalog seeding complete', { rowCount: finalRowCount });
} catch (error: any) {
logger.error('Failed to seed vehicle catalog', { error: error.message });
throw error;
}
}
/**
* Load and execute a SQL data file
*/
private async loadDataFile(filename: string): Promise<void> {
const filePath = path.join(this.dataDir, filename);
// Check if file exists
if (!fs.existsSync(filePath)) {
logger.warn('Data file not found, skipping', { filePath });
return;
}
logger.info('Loading data file', { filename });
try {
// Read SQL file
const sql = fs.readFileSync(filePath, 'utf-8');
// Execute SQL (pg library handles INSERT statements properly)
await this.pool.query(sql);
logger.info('Data file loaded successfully', { filename });
} catch (error: any) {
logger.error('Failed to load data file', { filename, error: error.message });
throw error;
}
}
}

View File

@@ -6,6 +6,8 @@ import { buildApp } from './app';
import { appConfig } from './core/config/config-loader'; import { appConfig } from './core/config/config-loader';
import { logger } from './core/logging/logger'; import { logger } from './core/logging/logger';
import { initializeScheduler } from './core/scheduler'; import { initializeScheduler } from './core/scheduler';
import { pool } from './core/config/database';
import { CatalogSeedService } from './features/platform/domain/catalog-seed.service';
const PORT = appConfig.config.server.port; const PORT = appConfig.config.server.port;
@@ -13,6 +15,15 @@ async function start() {
try { try {
const app = await buildApp(); const app = await buildApp();
// Seed vehicle catalog data if needed (runs after migrations)
try {
const catalogSeedService = new CatalogSeedService(pool);
await catalogSeedService.seedIfEmpty();
} catch (seedError) {
logger.warn('Vehicle catalog seeding failed, continuing startup', { seedError });
// Continue startup even if seeding fails (data can be imported later via admin UI)
}
await app.listen({ await app.listen({
port: PORT, port: PORT,
host: '0.0.0.0' host: '0.0.0.0'

View File

@@ -0,0 +1,21 @@
{
"active_stack": "blue",
"inactive_stack": "green",
"last_deployment": null,
"last_deployment_commit": null,
"last_deployment_status": null,
"blue": {
"version": null,
"commit": null,
"deployed_at": null,
"healthy": false
},
"green": {
"version": null,
"commit": null,
"deployed_at": null,
"healthy": false
},
"rollback_available": false,
"maintenance_mode": false
}

View File

@@ -0,0 +1,116 @@
# Traefik Dynamic Configuration for Blue-Green Deployment
# This file is watched by Traefik and reloaded on changes
# Traffic weights are updated by scripts/ci/switch-traffic.sh
#
# Current active stack is determined by weights:
# - blue=100, green=0 -> Blue is active
# - blue=0, green=100 -> Green is active
# - Gradual: 75/25, 50/50, 25/75 for canary deployments
http:
# ========================================
# Routers - Route traffic to weighted services
# ========================================
routers:
# Frontend router with weighted service
mvp-frontend-bluegreen:
rule: "(Host(`motovaultpro.com`) || Host(`www.motovaultpro.com`)) && !PathPrefix(`/api`)"
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: mvp-frontend-weighted
priority: 10
# Backend API router with weighted service
mvp-backend-bluegreen:
rule: "(Host(`motovaultpro.com`) || Host(`www.motovaultpro.com`)) && PathPrefix(`/api`)"
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: mvp-backend-weighted
priority: 20
# Health check router (always routes to active stack)
mvp-backend-health:
rule: "(Host(`motovaultpro.com`) || Host(`www.motovaultpro.com`)) && Path(`/api/health`)"
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: mvp-backend-weighted
priority: 30
# ========================================
# Services - Weighted load balancers
# ========================================
services:
# Frontend weighted service
# Weights are updated by switch-traffic.sh
mvp-frontend-weighted:
weighted:
services:
- name: mvp-frontend-blue-svc
weight: 100
- name: mvp-frontend-green-svc
weight: 0
healthCheck: {}
# Backend weighted service
# Weights are updated by switch-traffic.sh
mvp-backend-weighted:
weighted:
services:
- name: mvp-backend-blue-svc
weight: 100
- name: mvp-backend-green-svc
weight: 0
healthCheck: {}
# Individual stack services
mvp-frontend-blue-svc:
loadBalancer:
servers:
- url: "http://mvp-frontend-blue:3000"
healthCheck:
path: /
interval: 10s
timeout: 3s
passHostHeader: true
mvp-frontend-green-svc:
loadBalancer:
servers:
- url: "http://mvp-frontend-green:3000"
healthCheck:
path: /
interval: 10s
timeout: 3s
passHostHeader: true
mvp-backend-blue-svc:
loadBalancer:
servers:
- url: "http://mvp-backend-blue:3001"
healthCheck:
path: /health
interval: 10s
timeout: 3s
passHostHeader: true
mvp-backend-green-svc:
loadBalancer:
servers:
- url: "http://mvp-backend-green:3001"
healthCheck:
path: /health
interval: 10s
timeout: 3s
passHostHeader: true
# Maintenance mode service (optional)
mvp-maintenance:
loadBalancer:
servers:
- url: "http://mvp-maintenance:80"

View File

@@ -0,0 +1,180 @@
http:
middlewares:
# Security headers middleware
secure-headers:
headers:
accessControlAllowMethods:
- GET
- OPTIONS
- PUT
- POST
- DELETE
accessControlAllowOriginList:
- "https://admin.motovaultpro.com"
- "https://motovaultpro.com"
accessControlMaxAge: 100
addVaryHeader: true
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
frameDeny: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
customRequestHeaders:
X-Forwarded-Proto: https
# CORS middleware for API endpoints
cors:
headers:
accessControlAllowCredentials: true
accessControlAllowHeaders:
- "Authorization"
- "Content-Type"
- "X-Requested-With"
- "X-Tenant-ID"
accessControlAllowMethods:
- "GET"
- "POST"
- "PUT"
- "DELETE"
- "OPTIONS"
accessControlAllowOriginList:
- "https://admin.motovaultpro.com"
- "https://motovaultpro.com"
accessControlMaxAge: 100
# API authentication middleware
api-auth:
forwardAuth:
address: "http://admin-backend:3001/auth/verify"
authResponseHeaders:
- "X-Auth-User"
- "X-Auth-Roles"
- "X-Tenant-ID"
authRequestHeaders:
- "Authorization"
- "X-Tenant-ID"
trustForwardHeader: true
# Platform API authentication middleware
platform-auth:
forwardAuth:
address: "http://admin-backend:3001/auth/verify-platform"
authResponseHeaders:
- "X-Service-Name"
- "X-Auth-Scope"
authRequestHeaders:
- "X-API-Key"
- "Authorization"
trustForwardHeader: true
# Rate limiting middleware
rate-limit:
rateLimit:
burst: 100
average: 50
period: 1m
# Request/response size limits
size-limit:
buffering:
maxRequestBodyBytes: 26214400 # 25MB
maxResponseBodyBytes: 26214400 # 25MB
# IP whitelist for development (optional)
local-ips:
ipWhiteList:
sourceRange:
- "127.0.0.1/32"
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
# Advanced security headers for production
security-headers-strict:
headers:
accessControlAllowCredentials: false
accessControlAllowMethods:
- GET
- POST
- OPTIONS
accessControlAllowOriginList:
- "https://admin.motovaultpro.com"
- "https://motovaultpro.com"
browserXssFilter: true
contentTypeNosniff: true
customRequestHeaders:
X-Forwarded-Proto: https
customResponseHeaders:
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: "geolocation=(), microphone=(), camera=()"
forceSTSHeader: true
frameDeny: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
# Circuit breaker for reliability
circuit-breaker:
circuitBreaker:
expression: "NetworkErrorRatio() > 0.3 || ResponseCodeRatio(500, 600, 0, 600) > 0.3"
checkPeriod: 30s
fallbackDuration: 10s
recoveryDuration: 30s
# Request retry for resilience
retry-policy:
retry:
attempts: 3
initialInterval: 100ms
# Timeout middleware
timeout:
timeout: 30s
# Compress responses for performance
compression:
compress: {}
# Health check middleware chain
health-check-chain:
chain:
middlewares:
- compression
- secure-headers
- timeout
# API middleware chain
api-chain:
chain:
middlewares:
- compression
- security-headers-strict
- cors
- rate-limit
- api-auth
- retry-policy
- timeout
# Platform API middleware chain
platform-chain:
chain:
middlewares:
- compression
- security-headers-strict
- rate-limit
- platform-auth
- circuit-breaker
- retry-policy
- timeout
# Public frontend middleware chain
frontend-chain:
chain:
middlewares:
- compression
- secure-headers
- timeout

View File

@@ -21,7 +21,8 @@ providers:
exposedByDefault: false exposedByDefault: false
# Network auto-discovery - Traefik will use the networks it's connected to # Network auto-discovery - Traefik will use the networks it's connected to
file: file:
filename: /etc/traefik/middleware.yml # Watch directory for dynamic configuration (blue-green routing, middleware)
directory: /etc/traefik/dynamic
watch: true watch: true
certificatesResolvers: certificatesResolvers:

View File

View File

@@ -1,84 +0,0 @@
# Vehicle Catalog Data Export
Export the current vehicle catalog database to SQL files for GitLab CI/CD deployment.
## Export Workflow
### Export from Running Database
```bash
cd data/vehicle-etl
python3 export_from_postgres.py
```
**Output:** Creates output/01_engines.sql, output/02_transmissions.sql, output/03_vehicle_options.sql
**Requirements:**
- mvp-postgres container running
- Python 3.7+
### Commit and Deploy
```bash
git add output/*.sql
git commit -m "Update vehicle catalog data from PostgreSQL export"
git push
```
GitLab CI/CD will automatically import these SQL files during deployment.
---
## When to Export
| Scenario | Action |
|----------|--------|
| Admin uploaded CSVs to database | Export and commit |
| Manual corrections in PostgreSQL | Export and commit |
| After adding new vehicle data | Export and commit |
| Preparing for deployment | Export and commit |
---
## Local Testing
```bash
# Export current database state
python3 export_from_postgres.py
# Test import locally
./reset_database.sh
./import_data.sh
docker compose exec mvp-redis redis-cli FLUSHALL
# Verify data
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "
SELECT
(SELECT COUNT(*) FROM engines) as engines,
(SELECT COUNT(*) FROM transmissions) as transmissions,
(SELECT COUNT(*) FROM vehicle_options) as vehicle_options,
(SELECT MIN(year) FROM vehicle_options) as min_year,
(SELECT MAX(year) FROM vehicle_options) as max_year;
"
```
---
## GitLab CI/CD Integration
The pipeline automatically imports SQL files from `output/` directory during deployment (/.gitlab-ci.yml lines 89-98):
- data/vehicle-etl/output/01_engines.sql
- data/vehicle-etl/output/02_transmissions.sql
- data/vehicle-etl/output/03_vehicle_options.sql
Commit updated SQL files to trigger deployment with new data.
---
## Legacy Scripts (Not Used)
The following scripts are legacy from the VehAPI integration and are no longer used:
- vehapi_fetch_snapshot.py (obsolete - VehAPI not used)
- etl_generate_sql.py (obsolete - database export used instead)
These scripts are preserved for historical reference but should not be executed.

View File

@@ -1,322 +0,0 @@
#!/usr/bin/env python3
"""
Export PostgreSQL database to SQL files.
Extracts current state from running mvp-postgres container and generates
SQL import files compatible with the GitLab CI/CD pipeline.
Usage:
python3 export_from_postgres.py
python3 export_from_postgres.py --output-dir custom/path
Output files:
- output/01_engines.sql
- output/02_transmissions.sql
- output/03_vehicle_options.sql
"""
import argparse
import csv
import io
import subprocess
import sys
from pathlib import Path
from typing import Dict, Iterable, List, Sequence
BATCH_SIZE = 1000
def check_python_version():
"""Ensure Python 3.7+ is being used."""
if sys.version_info < (3, 7):
raise RuntimeError(
f"Python 3.7 or higher required. Current version: {sys.version_info.major}.{sys.version_info.minor}"
)
def check_container_running():
"""Verify mvp-postgres container is running."""
try:
result = subprocess.run(
["docker", "ps", "--filter", "name=mvp-postgres", "--format", "{{.Names}}"],
capture_output=True,
text=True,
check=True,
)
if "mvp-postgres" not in result.stdout:
raise RuntimeError(
"mvp-postgres container is not running.\n"
"Start with: docker compose up -d mvp-postgres"
)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to check Docker containers: {e}")
def sql_value(value):
"""
Convert a Python value to its SQL representation.
- None -> NULL
- str -> 'escaped string' (single quotes doubled)
- int/other -> str(value)
"""
if value is None:
return "NULL"
if isinstance(value, str):
return "'" + value.replace("'", "''") + "'"
return str(value)
def chunked(seq: Iterable[Dict], size: int) -> Iterable[List[Dict]]:
"""
Yield successive chunks of `size` from sequence.
Used to batch INSERT statements for better performance.
"""
chunk: List[Dict] = []
for item in seq:
chunk.append(item)
if len(chunk) >= size:
yield chunk
chunk = []
if chunk:
yield chunk
def write_insert_file(
path: Path,
table: str,
columns: Sequence[str],
rows: Sequence[Dict],
):
"""
Write batched INSERT statements to a SQL file.
Args:
path: Output file path
table: Table name
columns: Column names to insert
rows: List of row dictionaries
"""
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
f.write(f"-- Auto-generated by export_from_postgres.py\n")
if not rows:
f.write(f"-- No rows for {table}\n")
return
for batch in chunked(rows, BATCH_SIZE):
values_sql = ",\n".join(
"(" + ",".join(sql_value(row[col]) for col in columns) + ")"
for row in batch
)
f.write(f"INSERT INTO {table} ({', '.join(columns)}) VALUES\n{values_sql};\n\n")
def execute_psql_copy(query: str) -> str:
"""
Execute a PostgreSQL COPY command via docker exec.
Args:
query: SQL COPY query to execute
Returns:
CSV output as string
Raises:
RuntimeError: If command fails
"""
try:
result = subprocess.run(
[
"docker",
"exec",
"mvp-postgres",
"psql",
"-U",
"postgres",
"-d",
"motovaultpro",
"-c",
query,
],
capture_output=True,
text=True,
check=True,
)
return result.stdout
except subprocess.CalledProcessError as e:
error_msg = e.stderr if e.stderr else str(e)
raise RuntimeError(f"PostgreSQL query failed: {error_msg}")
def export_engines(output_dir: Path) -> int:
"""
Export engines table to 01_engines.sql.
Returns:
Number of records exported
"""
query = "COPY (SELECT id, name, fuel_type FROM engines ORDER BY id) TO STDOUT WITH CSV HEADER"
csv_output = execute_psql_copy(query)
rows = []
try:
reader = csv.DictReader(io.StringIO(csv_output))
for row in reader:
rows.append({
"id": int(row["id"]),
"name": row["name"],
"fuel_type": row["fuel_type"] if row["fuel_type"] else None,
})
except (csv.Error, KeyError, ValueError) as e:
raise RuntimeError(f"Failed to parse engines CSV output: {e}")
write_insert_file(
output_dir / "01_engines.sql",
"engines",
["id", "name", "fuel_type"],
rows,
)
return len(rows)
def export_transmissions(output_dir: Path) -> int:
"""
Export transmissions table to 02_transmissions.sql.
Returns:
Number of records exported
"""
query = "COPY (SELECT id, type FROM transmissions ORDER BY id) TO STDOUT WITH CSV HEADER"
csv_output = execute_psql_copy(query)
rows = []
try:
reader = csv.DictReader(io.StringIO(csv_output))
for row in reader:
rows.append({
"id": int(row["id"]),
"type": row["type"],
})
except (csv.Error, KeyError, ValueError) as e:
raise RuntimeError(f"Failed to parse transmissions CSV output: {e}")
write_insert_file(
output_dir / "02_transmissions.sql",
"transmissions",
["id", "type"],
rows,
)
return len(rows)
def export_vehicle_options(output_dir: Path) -> tuple:
"""
Export vehicle_options table to 03_vehicle_options.sql.
Returns:
Tuple of (record_count, min_year, max_year)
"""
query = """COPY (
SELECT year, make, model, trim, engine_id, transmission_id
FROM vehicle_options
ORDER BY year, make, model, trim
) TO STDOUT WITH CSV HEADER"""
csv_output = execute_psql_copy(query)
rows = []
years = []
try:
reader = csv.DictReader(io.StringIO(csv_output))
for row in reader:
year = int(row["year"])
years.append(year)
rows.append({
"year": year,
"make": row["make"],
"model": row["model"],
"trim": row["trim"],
"engine_id": int(row["engine_id"]) if row["engine_id"] else None,
"transmission_id": int(row["transmission_id"]) if row["transmission_id"] else None,
})
except (csv.Error, KeyError, ValueError) as e:
raise RuntimeError(f"Failed to parse vehicle_options CSV output: {e}")
write_insert_file(
output_dir / "03_vehicle_options.sql",
"vehicle_options",
["year", "make", "model", "trim", "engine_id", "transmission_id"],
rows,
)
min_year = min(years) if years else None
max_year = max(years) if years else None
return len(rows), min_year, max_year
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Export PostgreSQL vehicle catalog to SQL files.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=Path("output"),
help="Directory to write SQL output files (default: output)",
)
return parser.parse_args()
def main():
"""Main export workflow."""
check_python_version()
args = parse_args()
output_dir: Path = args.output_dir
print("Exporting from PostgreSQL database...")
print()
# Verify container is running
try:
check_container_running()
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Export each table
try:
engines_count = export_engines(output_dir)
print(f" Engines: {engines_count:,} records")
trans_count = export_transmissions(output_dir)
print(f" Transmissions: {trans_count:,} records")
vehicles_count, min_year, max_year = export_vehicle_options(output_dir)
print(f" Vehicle options: {vehicles_count:,} records")
print()
except RuntimeError as e:
print(f"Error during export: {e}", file=sys.stderr)
sys.exit(1)
# Print summary
print("SQL files generated:")
for sql_file in sorted(output_dir.glob("*.sql")):
size_kb = sql_file.stat().st_size / 1024
print(f" - {sql_file} ({size_kb:.0f}KB)")
print()
if min_year and max_year:
print(f"Year coverage: {min_year}-{max_year}")
print()
print("Export complete! Commit these files to deploy:")
print(f" git add {output_dir}/*.sql")
print(f" git commit -m \"Update vehicle catalog from PostgreSQL export ({min_year}-{max_year})\"")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -1,117 +0,0 @@
#!/bin/bash
#
# Vehicle Catalog CSV Bulk Import Wrapper
#
# Copies CSV file into mvp-backend container and executes bulk import script.
# Handles large CSV files (250k+ rows) that fail in web import.
#
# Usage:
# ./import_catalog.sh <path_to_csv_file>
#
# Example:
# ./import_catalog.sh data/vehicle-etl/import/vehicle-catalog-master.csv
#
# Requirements:
# - mvp-backend container must be running
# - CSV file must have headers: year, make, model, trim
# - Optional headers: engine_name, transmission_type
#
set -euo pipefail
CONTAINER="mvp-backend"
TEMP_CSV_PATH="/tmp/catalog-import.csv"
SCRIPT_PATH="dist/features/admin/scripts/bulk-import-catalog.js"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Print error and exit
error() {
echo -e "${RED}Error: $1${NC}" >&2
exit 1
}
# Print success message
success() {
echo -e "${GREEN}$1${NC}"
}
# Print warning message
warn() {
echo -e "${YELLOW}$1${NC}"
}
# Check if CSV file argument provided
if [ $# -eq 0 ]; then
error "No CSV file specified.
Usage: $0 <path_to_csv_file>
Example:
$0 data/vehicle-etl/import/vehicle-catalog-master.csv"
fi
CSV_FILE="$1"
# Validate CSV file exists
if [ ! -f "$CSV_FILE" ]; then
error "CSV file not found: $CSV_FILE"
fi
# Get absolute path to CSV file
CSV_FILE_ABS=$(cd "$(dirname "$CSV_FILE")" && pwd)/$(basename "$CSV_FILE")
# Check if container is running
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
error "Container '${CONTAINER}' is not running. Start it with: make start"
fi
echo "=========================================="
echo "Vehicle Catalog Bulk Import"
echo "=========================================="
echo "CSV File: $CSV_FILE_ABS"
echo "Container: $CONTAINER"
echo ""
# Copy CSV file into container
echo "Step 1: Copying CSV file into container..."
if ! docker cp "$CSV_FILE_ABS" "${CONTAINER}:${TEMP_CSV_PATH}"; then
error "Failed to copy CSV file into container"
fi
success "CSV file copied successfully"
echo ""
# Execute import script inside container
echo "Step 2: Running import script..."
echo ""
if docker exec -it "$CONTAINER" node "$SCRIPT_PATH"; then
success "Import completed successfully!"
IMPORT_SUCCESS=true
else
error "Import failed. Check the logs above for details."
IMPORT_SUCCESS=false
fi
# Cleanup: Remove temp CSV file from container
echo ""
echo "Step 3: Cleaning up..."
if docker exec "$CONTAINER" rm -f "$TEMP_CSV_PATH" 2>/dev/null; then
success "Temporary files cleaned up"
else
warn "Warning: Failed to cleanup temp CSV file in container"
fi
echo ""
if [ "$IMPORT_SUCCESS" = true ]; then
echo "=========================================="
success "Import process completed successfully!"
echo "=========================================="
exit 0
else
exit 1
fi

View File

@@ -1,71 +0,0 @@
#!/bin/bash
# Offline import of generated SQL files into PostgreSQL (no network).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "=========================================="
echo "📥 Automotive Database Import (offline)"
echo "=========================================="
echo ""
require_file() {
if [ ! -f "$1" ]; then
echo "❌ Missing required file: $1"
exit 1
fi
}
if ! docker ps --filter "name=mvp-postgres" --format "{{.Names}}" | grep -q "mvp-postgres"; then
echo "❌ Error: mvp-postgres container is not running"
exit 1
fi
require_file "output/01_engines.sql"
require_file "output/02_transmissions.sql"
require_file "output/03_vehicle_options.sql"
echo "📋 Step 1: Running database schema migration..."
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < migrations/001_create_vehicle_database.sql
echo "✓ Schema migration completed"
echo ""
echo "🧹 Step 2: Truncating existing data..."
docker exec -i mvp-postgres psql -U postgres -d motovaultpro <<'EOF'
TRUNCATE TABLE vehicle_options RESTART IDENTITY CASCADE;
TRUNCATE TABLE engines RESTART IDENTITY CASCADE;
TRUNCATE TABLE transmissions RESTART IDENTITY CASCADE;
EOF
echo "✓ Tables truncated"
echo ""
echo "📥 Step 3: Importing engines..."
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < output/01_engines.sql
echo "✓ Engines imported"
echo ""
echo "📥 Step 4: Importing transmissions..."
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < output/02_transmissions.sql
echo "✓ Transmissions imported"
echo ""
echo "📥 Step 5: Importing vehicle options (observed pairs only)..."
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < output/03_vehicle_options.sql
echo "✓ Vehicle options imported"
echo ""
echo "=========================================="
echo "✅ Import completed"
echo "=========================================="
echo ""
echo "🔍 Database verification:"
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT COUNT(*) as engines FROM engines;"
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT COUNT(*) as transmissions FROM transmissions;"
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT COUNT(*) as vehicle_options FROM vehicle_options;"
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT MIN(year) as min_year, MAX(year) as max_year FROM vehicle_options;"
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT DISTINCT year FROM vehicle_options ORDER BY year LIMIT 5;"
docker exec mvp-postgres psql -U postgres -d motovaultpro -c "SELECT DISTINCT year FROM vehicle_options ORDER BY year DESC LIMIT 5;"
echo ""
echo "✓ Database ready for dropdown use."

View File

@@ -1,293 +0,0 @@
-- Migration: Create Automotive Vehicle Selection Database
-- Optimized for dropdown cascade queries
-- Date: 2025-11-10
-- Drop existing tables if they exist
DROP TABLE IF EXISTS vehicle_options CASCADE;
DROP TABLE IF EXISTS engines CASCADE;
DROP TABLE IF EXISTS transmissions CASCADE;
DROP INDEX IF EXISTS idx_vehicle_year;
DROP INDEX IF EXISTS idx_vehicle_make;
DROP INDEX IF EXISTS idx_vehicle_model;
DROP INDEX IF EXISTS idx_vehicle_trim;
DROP INDEX IF EXISTS idx_vehicle_composite;
-- Create engines table with detailed specifications
CREATE TABLE engines (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
displacement VARCHAR(50),
configuration VARCHAR(50),
horsepower VARCHAR(100),
torque VARCHAR(100),
fuel_type VARCHAR(100),
fuel_system VARCHAR(255),
aspiration VARCHAR(100),
specs_json JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Prevent duplicate engine display names (case-insensitive)
CREATE UNIQUE INDEX IF NOT EXISTS uq_engines_name_lower ON engines (LOWER(name));
CREATE INDEX idx_engines_displacement ON engines(displacement);
CREATE INDEX idx_engines_config ON engines(configuration);
-- Create transmissions table
CREATE TABLE transmissions (
id SERIAL PRIMARY KEY,
type VARCHAR(100) NOT NULL,
speeds VARCHAR(50),
drive_type VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Prevent duplicate transmission display names (case-insensitive)
CREATE UNIQUE INDEX IF NOT EXISTS uq_transmissions_type_lower ON transmissions (LOWER(type));
CREATE INDEX idx_transmissions_type ON transmissions(type);
-- Create denormalized vehicle_options table optimized for dropdown queries
CREATE TABLE vehicle_options (
id SERIAL PRIMARY KEY,
year INTEGER NOT NULL,
make VARCHAR(100) NOT NULL,
model VARCHAR(255) NOT NULL,
trim VARCHAR(255) NOT NULL,
engine_id INTEGER REFERENCES engines(id) ON DELETE SET NULL,
transmission_id INTEGER REFERENCES transmissions(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Prevent duplicate vehicle option rows
CREATE UNIQUE INDEX IF NOT EXISTS uq_vehicle_options_full ON vehicle_options (
year, make, model, trim, engine_id, transmission_id
);
-- Indexes for cascading dropdown performance
CREATE INDEX idx_vehicle_year ON vehicle_options(year);
CREATE INDEX idx_vehicle_make ON vehicle_options(make);
CREATE INDEX idx_vehicle_model ON vehicle_options(model);
CREATE INDEX idx_vehicle_trim ON vehicle_options(trim);
CREATE INDEX idx_vehicle_year_make ON vehicle_options(year, make);
CREATE INDEX idx_vehicle_year_make_model ON vehicle_options(year, make, model);
CREATE INDEX idx_vehicle_year_make_model_trim ON vehicle_options(year, make, model, trim);
CREATE INDEX idx_vehicle_year_make_model_trim_engine ON vehicle_options(year, make, model, trim, engine_id);
CREATE INDEX idx_vehicle_year_make_model_trim_trans ON vehicle_options(year, make, model, trim, transmission_id);
-- Full-text search index for admin catalog search
CREATE INDEX idx_vehicle_options_fts ON vehicle_options
USING gin(to_tsvector('english', year::text || ' ' || make || ' ' || model || ' ' || trim));
-- Index on engines.name for join performance during search
CREATE INDEX idx_engines_name ON engines(name);
-- Views for dropdown queries
-- View: Get all available years
CREATE OR REPLACE VIEW available_years AS
SELECT DISTINCT year
FROM vehicle_options
ORDER BY year DESC;
-- View: Get makes by year
CREATE OR REPLACE VIEW makes_by_year AS
SELECT DISTINCT year, make
FROM vehicle_options
ORDER BY year DESC, make ASC;
-- View: Get models by year and make
CREATE OR REPLACE VIEW models_by_year_make AS
SELECT DISTINCT year, make, model
FROM vehicle_options
ORDER BY year DESC, make ASC, model ASC;
-- View: Get trims by year, make, and model
CREATE OR REPLACE VIEW trims_by_year_make_model AS
SELECT DISTINCT year, make, model, trim
FROM vehicle_options
ORDER BY year DESC, make ASC, model ASC, trim ASC;
-- View: Get complete vehicle configurations with engine and transmission details
CREATE OR REPLACE VIEW complete_vehicle_configs AS
SELECT
vo.id,
vo.year,
vo.make,
vo.model,
vo.trim,
e.name AS engine_name,
e.displacement,
e.configuration,
e.horsepower,
e.torque,
e.fuel_type,
t.type AS transmission_type,
t.speeds AS transmission_speeds,
t.drive_type
FROM vehicle_options vo
LEFT JOIN engines e ON vo.engine_id = e.id
LEFT JOIN transmissions t ON vo.transmission_id = t.id
ORDER BY vo.year DESC, vo.make ASC, vo.model ASC, vo.trim ASC;
-- Function to get makes for a specific year
CREATE OR REPLACE FUNCTION get_makes_for_year(p_year INTEGER)
RETURNS TABLE(make VARCHAR) AS $$
BEGIN
RETURN QUERY
SELECT DISTINCT vehicle_options.make
FROM vehicle_options
WHERE vehicle_options.year = p_year
ORDER BY vehicle_options.make ASC;
END;
$$ LANGUAGE plpgsql;
-- Function to get models for a specific year and make
CREATE OR REPLACE FUNCTION get_models_for_year_make(p_year INTEGER, p_make VARCHAR)
RETURNS TABLE(model VARCHAR) AS $$
BEGIN
RETURN QUERY
SELECT DISTINCT vehicle_options.model
FROM vehicle_options
WHERE vehicle_options.year = p_year
AND vehicle_options.make = p_make
ORDER BY vehicle_options.model ASC;
END;
$$ LANGUAGE plpgsql;
-- Function to get trims for a specific year, make, and model
CREATE OR REPLACE FUNCTION get_trims_for_year_make_model(p_year INTEGER, p_make VARCHAR, p_model VARCHAR)
RETURNS TABLE(trim_name VARCHAR) AS $$
BEGIN
RETURN QUERY
SELECT DISTINCT vehicle_options.trim
FROM vehicle_options
WHERE vehicle_options.year = p_year
AND vehicle_options.make = p_make
AND vehicle_options.model = p_model
ORDER BY vehicle_options.trim ASC;
END;
$$ LANGUAGE plpgsql;
-- Function to get engine and transmission options for a specific vehicle
CREATE OR REPLACE FUNCTION get_options_for_vehicle(p_year INTEGER, p_make VARCHAR, p_model VARCHAR, p_trim VARCHAR)
RETURNS TABLE(
engine_name VARCHAR,
engine_displacement VARCHAR,
engine_horsepower VARCHAR,
transmission_type VARCHAR,
transmission_speeds VARCHAR,
drive_type VARCHAR
) AS $$
BEGIN
RETURN QUERY
SELECT
e.name,
e.displacement,
e.horsepower,
t.type,
t.speeds,
t.drive_type
FROM vehicle_options vo
LEFT JOIN engines e ON vo.engine_id = e.id
LEFT JOIN transmissions t ON vo.transmission_id = t.id
WHERE vo.year = p_year
AND vo.make = p_make
AND vo.model = p_model
AND vo.trim = p_trim;
END;
$$ LANGUAGE plpgsql;
-- Helper functions for trim-level options and pair-safe filtering
CREATE OR REPLACE FUNCTION get_transmissions_for_vehicle(p_year INTEGER, p_make VARCHAR, p_model VARCHAR, p_trim VARCHAR)
RETURNS TABLE(
transmission_id INTEGER,
transmission_type VARCHAR
) AS $$
BEGIN
RETURN QUERY
SELECT DISTINCT
t.id,
t.type
FROM vehicle_options vo
JOIN transmissions t ON vo.transmission_id = t.id
WHERE vo.year = p_year
AND vo.make = p_make
AND vo.model = p_model
AND vo.trim = p_trim
ORDER BY t.type ASC;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION get_engines_for_vehicle(p_year INTEGER, p_make VARCHAR, p_model VARCHAR, p_trim VARCHAR)
RETURNS TABLE(
engine_id INTEGER,
engine_name VARCHAR
) AS $$
BEGIN
RETURN QUERY
SELECT DISTINCT
e.id,
e.name
FROM vehicle_options vo
JOIN engines e ON vo.engine_id = e.id
WHERE vo.year = p_year
AND vo.make = p_make
AND vo.model = p_model
AND vo.trim = p_trim
ORDER BY e.name ASC;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION get_transmissions_for_vehicle_engine(p_year INTEGER, p_make VARCHAR, p_model VARCHAR, p_trim VARCHAR, p_engine_name VARCHAR)
RETURNS TABLE(
transmission_id INTEGER,
transmission_type VARCHAR
) AS $$
BEGIN
RETURN QUERY
SELECT DISTINCT
t.id,
t.type
FROM vehicle_options vo
JOIN engines e ON vo.engine_id = e.id
JOIN transmissions t ON vo.transmission_id = t.id
WHERE vo.year = p_year
AND vo.make = p_make
AND vo.model = p_model
AND vo.trim = p_trim
AND e.name = p_engine_name
ORDER BY t.type ASC;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION get_engines_for_vehicle_trans(p_year INTEGER, p_make VARCHAR, p_model VARCHAR, p_trim VARCHAR, p_trans_type VARCHAR)
RETURNS TABLE(
engine_id INTEGER,
engine_name VARCHAR
) AS $$
BEGIN
RETURN QUERY
SELECT DISTINCT
e.id,
e.name
FROM vehicle_options vo
JOIN engines e ON vo.engine_id = e.id
JOIN transmissions t ON vo.transmission_id = t.id
WHERE vo.year = p_year
AND vo.make = p_make
AND vo.model = p_model
AND vo.trim = p_trim
AND t.type = p_trans_type
ORDER BY e.name ASC;
END;
$$ LANGUAGE plpgsql;
COMMENT ON TABLE vehicle_options IS 'Denormalized table optimized for cascading dropdown queries';
COMMENT ON TABLE engines IS 'Engine specifications with detailed technical data';
COMMENT ON TABLE transmissions IS 'Transmission specifications';
COMMENT ON VIEW available_years IS 'Returns all distinct years available in the database';
COMMENT ON VIEW makes_by_year IS 'Returns makes grouped by year for dropdown population';
COMMENT ON VIEW models_by_year_make IS 'Returns models grouped by year and make';
COMMENT ON VIEW trims_by_year_make_model IS 'Returns trims grouped by year, make, and model';
COMMENT ON VIEW complete_vehicle_configs IS 'Complete vehicle configurations with all details';

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +0,0 @@
-- Auto-generated by export_from_postgres.py
INSERT INTO transmissions (id, type) VALUES
(1,'Automatic'),
(2,'Manual'),
(3,'3-Speed Automatic'),
(4,'5-Speed Manual'),
(5,'4-Speed Manual'),
(6,'3-Speed Manual'),
(7,'4-Speed Automatic'),
(8,'6-Speed Manual'),
(9,'4-Speed Automatic Overdrive'),
(10,'5-Speed Manual Overdrive'),
(11,'Continuously Variable Transmission'),
(12,'5-Speed Automatic'),
(13,'6-Speed Manual Overdrive'),
(14,'1-Speed Dual Clutch'),
(15,'5-Speed Automatic Overdrive'),
(16,'6-Speed Automatic'),
(17,'6-Speed Automatic Overdrive'),
(18,'6-Speed CVT'),
(19,'7-Speed Automatic'),
(20,'6-Speed Dual Clutch'),
(21,'8-Speed Automatic'),
(22,'1-Speed Automatic'),
(23,'7-Speed Dual Clutch'),
(24,'5-Speed Dual Clutch'),
(25,'7-Speed CVT'),
(26,'7-Speed Manual'),
(27,'9-Speed Automatic'),
(28,'8-Speed Dual Clutch'),
(29,'8-Speed CVT'),
(30,'9-Speed Dual Clutch'),
(31,'10-Speed Automatic'),
(32,'4-Speed CVT'),
(33,'10-Speed Dual Clutch'),
(34,'10-Speed CVT'),
(35,'2-Speed Automatic'),
(36,'10-Speed Automatic Transmission'),
(115,'CVT'),
(119,'1-Speed Direct Drive'),
(1159,'8-Speed DCT'),
(1172,'7-Speed DCT'),
(1184,'9-Speed DCT'),
(3072,'Single-Speed Transmission'),
(5081,'Electric'),
(5304,'ISR Automatic');

File diff suppressed because it is too large Load Diff

View File

@@ -1,190 +0,0 @@
#!/usr/bin/env python3
"""
Post-import QA validation for vehicle dropdown data.
Runs basic duplicate and range checks against the motovaultpro Postgres container.
"""
import os
import subprocess
import sys
def run_psql(query: str) -> str:
cmd = [
"docker",
"exec",
"mvp-postgres",
"psql",
"-U",
"postgres",
"-d",
"motovaultpro",
"-At",
"-c",
query,
]
return subprocess.check_output(cmd, text=True)
def check_container():
try:
subprocess.check_output(["docker", "ps"], text=True)
except Exception:
print("❌ Docker not available.")
sys.exit(1)
try:
containers = subprocess.check_output(
["docker", "ps", "--filter", "name=mvp-postgres", "--format", "{{.Names}}"],
text=True,
).strip()
if not containers:
print("❌ mvp-postgres container not running.")
sys.exit(1)
except Exception as exc:
print(f"❌ Failed to check containers: {exc}")
sys.exit(1)
def check_invalid_combinations():
"""Verify known invalid combinations do not exist."""
invalid_combos = [
(1992, "Chevrolet", "Corvette", "Z06"), # Z06 started 2001
(2000, "Chevrolet", "Corvette", "35th Anniversary Edition"), # Was 1988
(2000, "Chevrolet", "Corvette", "Stingray"), # Stingray started 2014
(1995, "Ford", "Mustang", "Mach-E"), # Mach-E is 2021+
(2020, "Tesla", "Cybertruck", "Base"), # Not in production until later
]
issues = []
for year, make, model, trim in invalid_combos:
query = f"""
SELECT COUNT(*) FROM vehicle_options
WHERE year = {year}
AND make = '{make}'
AND model = '{model}'
AND trim = '{trim}'
"""
count = int(run_psql(query).strip())
if count > 0:
issues.append(f"Invalid combo found: {year} {make} {model} {trim}")
return issues
def check_trim_coverage():
"""Report on trim coverage statistics."""
query = """
SELECT
COUNT(DISTINCT (year, make, model)) as total_models,
COUNT(DISTINCT (year, make, model)) FILTER (WHERE trim = 'Base') as base_only,
COUNT(DISTINCT (year, make, model)) FILTER (WHERE trim != 'Base') as has_specific_trims
FROM vehicle_options
"""
result = run_psql(query).strip()
print(f"Trim coverage (total/base_only/has_specific_trims): {result}")
def main():
check_container()
print("🔍 Running QA checks...\n")
queries = {
"engine_duplicate_names": """
SELECT COUNT(*) FROM (
SELECT LOWER(name) as n, COUNT(*) c
FROM engines
GROUP BY 1 HAVING COUNT(*) > 1
) t;
""",
"transmission_duplicate_types": """
SELECT COUNT(*) FROM (
SELECT LOWER(type) as t, COUNT(*) c
FROM transmissions
GROUP BY 1 HAVING COUNT(*) > 1
) t;
""",
"vehicle_option_duplicates": """
SELECT COUNT(*) FROM (
SELECT year, make, model, trim, engine_id, transmission_id, COUNT(*) c
FROM vehicle_options
GROUP BY 1,2,3,4,5,6 HAVING COUNT(*) > 1
) t;
""",
"year_range": """
SELECT MIN(year) || ' - ' || MAX(year) FROM vehicle_options;
""",
"year_range_valid": """
SELECT COUNT(*) FROM (
SELECT 1 FROM vehicle_options WHERE year < 2015 OR year > 2022 LIMIT 1
) t;
""",
"counts": """
SELECT
(SELECT COUNT(*) FROM engines) AS engines,
(SELECT COUNT(*) FROM transmissions) AS transmissions,
(SELECT COUNT(*) FROM vehicle_options) AS vehicle_options;
""",
"cross_join_gaps": """
SELECT COUNT(*) FROM (
SELECT base.year, base.make, base.model, base.trim, e.engine_id, t.transmission_id
FROM (
SELECT DISTINCT year, make, model, trim FROM vehicle_options
) base
JOIN (
SELECT DISTINCT year, make, model, trim, engine_id FROM vehicle_options
) e ON base.year = e.year AND base.make = e.make AND base.model = e.model AND base.trim = e.trim
JOIN (
SELECT DISTINCT year, make, model, trim, transmission_id FROM vehicle_options
) t ON base.year = t.year AND base.make = t.make AND base.model = t.model AND base.trim = t.trim
EXCEPT
SELECT year, make, model, trim, engine_id, transmission_id FROM vehicle_options
) gap;
""",
}
results = {}
for key, query in queries.items():
try:
results[key] = run_psql(query).strip()
except subprocess.CalledProcessError as exc:
print(f"❌ Query failed ({key}): {exc}")
sys.exit(1)
issues_found = False
print(f"Engine duplicate names: {results['engine_duplicate_names']}")
print(f"Transmission duplicate types: {results['transmission_duplicate_types']}")
print(f"Vehicle option duplicates: {results['vehicle_option_duplicates']}")
print(f"Year range: {results['year_range']}")
print(f"Out-of-range years (should be 0): {results['year_range_valid']}")
print(f"Counts (engines, transmissions, vehicle_options): {results['counts']}")
print(f"Cross-join gaps (should be 0 to avoid impossible pairs): {results['cross_join_gaps']}")
if (
results["engine_duplicate_names"] != "0"
or results["transmission_duplicate_types"] != "0"
or results["vehicle_option_duplicates"] != "0"
or results["year_range_valid"] != "0"
or results["cross_join_gaps"] != "0"
):
issues_found = True
invalids = check_invalid_combinations()
if invalids:
issues_found = True
print("\n❌ Invalid combinations detected:")
for issue in invalids:
print(f" - {issue}")
else:
print("\n✅ No known invalid year/make/model/trim combos found.")
check_trim_coverage()
if not issues_found:
print("\n✅ QA checks passed.")
else:
print("\n❌ QA checks found issues.")
if __name__ == "__main__":
main()

View File

@@ -1,56 +0,0 @@
#!/bin/bash
# Reset vehicle database tables before a fresh import.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "=========================================="
echo "Vehicle Database Reset"
echo "=========================================="
echo ""
# Check if postgres container is running
if ! docker ps --filter "name=mvp-postgres" --format "{{.Names}}" | grep -q "mvp-postgres"; then
echo "Error: mvp-postgres container is not running"
exit 1
fi
echo "Current data (before reset):"
docker exec mvp-postgres psql -U postgres -d motovaultpro -c \
"SELECT
(SELECT COUNT(*) FROM engines) as engines,
(SELECT COUNT(*) FROM transmissions) as transmissions,
(SELECT COUNT(*) FROM vehicle_options) as vehicle_options;" 2>/dev/null || echo " Tables may not exist yet"
echo ""
# Confirm reset
read -p "Are you sure you want to reset all vehicle data? (y/N) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Reset cancelled."
exit 0
fi
echo ""
echo "Truncating tables..."
docker exec -i mvp-postgres psql -U postgres -d motovaultpro <<'EOF'
TRUNCATE TABLE vehicle_options RESTART IDENTITY CASCADE;
TRUNCATE TABLE engines RESTART IDENTITY CASCADE;
TRUNCATE TABLE transmissions RESTART IDENTITY CASCADE;
EOF
echo ""
echo "=========================================="
echo "Reset complete"
echo "=========================================="
echo ""
echo "Verification (should all be 0):"
docker exec mvp-postgres psql -U postgres -d motovaultpro -c \
"SELECT
(SELECT COUNT(*) FROM engines) as engines,
(SELECT COUNT(*) FROM transmissions) as transmissions,
(SELECT COUNT(*) FROM vehicle_options) as vehicle_options;"
echo ""
echo "Ready for fresh import with: ./import_data.sh"

View File

@@ -1,34 +0,0 @@
#!/bin/bash
# Compare database counts with exported SQL file counts
# Usage: ./validate_export.sh
set -e
echo "Validating exported SQL files against database..."
echo ""
# Get counts from database
DB_ENGINES=$(docker exec mvp-postgres psql -U postgres -d motovaultpro -t -A -c "SELECT COUNT(*) FROM engines;")
DB_TRANS=$(docker exec mvp-postgres psql -U postgres -d motovaultpro -t -A -c "SELECT COUNT(*) FROM transmissions;")
DB_VEHICLES=$(docker exec mvp-postgres psql -U postgres -d motovaultpro -t -A -c "SELECT COUNT(*) FROM vehicle_options;")
# Count records in SQL files (count lines starting with '(' which are data rows)
SQL_ENGINES=$(grep -c '^(' output/01_engines.sql)
SQL_TRANS=$(grep -c '^(' output/02_transmissions.sql)
SQL_VEHICLES=$(grep -c '^(' output/03_vehicle_options.sql)
# Display comparison
echo "Database vs SQL File Counts:"
echo " Engines: $DB_ENGINES (DB) vs $SQL_ENGINES (SQL)"
echo " Transmissions: $DB_TRANS (DB) vs $SQL_TRANS (SQL)"
echo " Vehicle Options: $DB_VEHICLES (DB) vs $SQL_VEHICLES (SQL)"
echo ""
# Validate counts match
if [ "$DB_ENGINES" -eq "$SQL_ENGINES" ] && [ "$DB_TRANS" -eq "$SQL_TRANS" ] && [ "$DB_VEHICLES" -eq "$SQL_VEHICLES" ]; then
echo "Validation PASSED - All counts match!"
exit 0
else
echo "Validation FAILED - Counts do not match!"
exit 1
fi

View File

@@ -0,0 +1,196 @@
# Blue-Green Deployment Overlay for MotoVaultPro
# Usage: docker compose -f docker-compose.yml -f docker-compose.blue-green.yml up -d
#
# This overlay defines blue and green stacks that share the same database layer.
# Traffic routing is handled by Traefik's weighted load balancer.
#
# Stack naming:
# BLUE: mvp-frontend-blue, mvp-backend-blue
# GREEN: mvp-frontend-green, mvp-backend-green
#
# Shared services (from base compose):
# mvp-traefik, mvp-postgres, mvp-redis
services:
# ========================================
# BLUE Stack - Frontend
# ========================================
mvp-frontend-blue:
image: ${FRONTEND_IMAGE:-registry.motovaultpro.com/motovaultpro/frontend:latest}
container_name: mvp-frontend-blue
restart: unless-stopped
environment:
VITE_API_BASE_URL: /api
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
SECRETS_DIR: /run/secrets
volumes:
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
networks:
- frontend
depends_on:
- mvp-backend-blue
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 512M
labels:
- "traefik.enable=true"
- "traefik.docker.network=motovaultpro_frontend"
- "com.motovaultpro.stack=blue"
- "com.motovaultpro.service=frontend"
# ========================================
# BLUE Stack - Backend
# ========================================
mvp-backend-blue:
image: ${BACKEND_IMAGE:-registry.motovaultpro.com/motovaultpro/backend:latest}
container_name: mvp-backend-blue
restart: unless-stopped
environment:
NODE_ENV: production
CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets
DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis
volumes:
- ./config/app/production.yml:/app/config/production.yml:ro
- ./config/shared/production.yml:/app/config/shared.yml:ro
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
- ./data/documents:/app/data/documents
- ./data/backups:/app/data/backups
networks:
- backend
- database
depends_on:
- mvp-postgres
- mvp-redis
healthcheck:
test:
- CMD-SHELL
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
deploy:
resources:
limits:
memory: 1G
labels:
- "traefik.enable=true"
- "traefik.docker.network=motovaultpro_backend"
- "com.motovaultpro.stack=blue"
- "com.motovaultpro.service=backend"
# ========================================
# GREEN Stack - Frontend
# ========================================
mvp-frontend-green:
image: ${FRONTEND_IMAGE:-registry.motovaultpro.com/motovaultpro/frontend:latest}
container_name: mvp-frontend-green
restart: unless-stopped
environment:
VITE_API_BASE_URL: /api
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-motovaultpro.us.auth0.com}
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3}
VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-https://api.motovaultpro.com}
SECRETS_DIR: /run/secrets
volumes:
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
networks:
- frontend
depends_on:
- mvp-backend-green
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 512M
labels:
- "traefik.enable=true"
- "traefik.docker.network=motovaultpro_frontend"
- "com.motovaultpro.stack=green"
- "com.motovaultpro.service=frontend"
# ========================================
# GREEN Stack - Backend
# ========================================
mvp-backend-green:
image: ${BACKEND_IMAGE:-registry.motovaultpro.com/motovaultpro/backend:latest}
container_name: mvp-backend-green
restart: unless-stopped
environment:
NODE_ENV: production
CONFIG_PATH: /app/config/production.yml
SECRETS_DIR: /run/secrets
DATABASE_HOST: mvp-postgres
REDIS_HOST: mvp-redis
volumes:
- ./config/app/production.yml:/app/config/production.yml:ro
- ./config/shared/production.yml:/app/config/shared.yml:ro
- ./secrets/app/postgres-password.txt:/run/secrets/postgres-password:ro
- ./secrets/app/auth0-client-secret.txt:/run/secrets/auth0-client-secret:ro
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
- ./secrets/app/google-maps-map-id.txt:/run/secrets/google-maps-map-id:ro
- ./secrets/app/resend-api-key.txt:/run/secrets/resend-api-key:ro
- ./secrets/app/auth0-management-client-id.txt:/run/secrets/auth0-management-client-id:ro
- ./secrets/app/auth0-management-client-secret.txt:/run/secrets/auth0-management-client-secret:ro
- ./data/documents:/app/data/documents
- ./data/backups:/app/data/backups
networks:
- backend
- database
depends_on:
- mvp-postgres
- mvp-redis
healthcheck:
test:
- CMD-SHELL
- node -e "require('http').get('http://localhost:3001/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
deploy:
resources:
limits:
memory: 1G
labels:
- "traefik.enable=true"
- "traefik.docker.network=motovaultpro_backend"
- "com.motovaultpro.stack=green"
- "com.motovaultpro.service=backend"
# ========================================
# Override Traefik to add dynamic config
# ========================================
mvp-traefik:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./config/traefik/middleware.yml:/etc/traefik/middleware.yml:ro
- ./config/traefik/dynamic:/etc/traefik/dynamic:ro
- ./certs:/certs:ro
- traefik_data:/data
- ./secrets/app/cloudflare-dns-token.txt:/run/secrets/cloudflare-dns-token:ro

View File

@@ -1,7 +1,11 @@
# Base registry for mirrored images (override with environment variable)
x-registry: &registry
REGISTRY_MIRRORS: ${REGISTRY_MIRRORS:-registry.motovaultpro.com/mirrors}
services: services:
# Traefik - Service Discovery and Load Balancing # Traefik - Service Discovery and Load Balancing
mvp-traefik: mvp-traefik:
image: traefik:v3.6 image: ${REGISTRY_MIRRORS:-registry.motovaultpro.com/mirrors}/traefik:v3.6
container_name: mvp-traefik container_name: mvp-traefik
restart: unless-stopped restart: unless-stopped
command: command:
@@ -15,7 +19,7 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- ./config/traefik/traefik.yml:/etc/traefik/traefik.yml:ro - ./config/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./config/traefik/middleware.yml:/etc/traefik/middleware.yml:ro - ./config/traefik/dynamic:/etc/traefik/dynamic:ro
- ./certs:/certs:ro - ./certs:/certs:ro
- traefik_data:/data - traefik_data:/data
- ./secrets/app/cloudflare-dns-token.txt:/run/secrets/cloudflare-dns-token:ro - ./secrets/app/cloudflare-dns-token.txt:/run/secrets/cloudflare-dns-token:ro
@@ -86,8 +90,8 @@ services:
# Application Services - Backend API # Application Services - Backend API
mvp-backend: mvp-backend:
build: build:
context: ./backend context: .
dockerfile: Dockerfile dockerfile: backend/Dockerfile
cache_from: cache_from:
- node:lts-alpine - node:lts-alpine
container_name: mvp-backend container_name: mvp-backend
@@ -154,7 +158,7 @@ services:
# Database Services - Application PostgreSQL # Database Services - Application PostgreSQL
mvp-postgres: mvp-postgres:
image: postgres:18-alpine image: ${REGISTRY_MIRRORS:-registry.motovaultpro.com/mirrors}/postgres:18-alpine
container_name: mvp-postgres container_name: mvp-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -179,7 +183,7 @@ services:
# Database Services - Application Redis # Database Services - Application Redis
mvp-redis: mvp-redis:
image: redis:8.4-alpine image: ${REGISTRY_MIRRORS:-registry.motovaultpro.com/mirrors}/redis:8.4-alpine
container_name: mvp-redis container_name: mvp-redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes command: redis-server --appendonly yes

315
docs/BUILD-SERVER-SETUP.md Normal file
View File

@@ -0,0 +1,315 @@
# Build Server Setup Guide
Complete guide for setting up a dedicated build VPS for MotoVaultPro CI/CD pipeline.
## Overview
The build server isolates resource-intensive Docker builds from the production server, ensuring deployments don't impact application performance.
```
+-------------------+ +--------------------+
| GitLab Server | | Production Server |
| (CI/CD + Registry)| | (Shell Runner) |
+--------+----------+ +----------+---------+
| |
v v
+--------+----------+ +----------+---------+
| Build VPS | | Blue-Green Stacks |
| (Docker Runner) |---->| + Shared Data |
+-------------------+ +--------------------+
```
## Server Requirements
### Minimum Specifications
| Resource | Requirement |
|----------|-------------|
| CPU | 2 cores |
| RAM | 4GB |
| Storage | 50GB SSD |
| Network | 100Mbps+ |
| OS | Ubuntu 22.04 LTS / Debian 12 |
### Network Requirements
- Outbound HTTPS to GitLab instance
- Outbound HTTPS to Docker registries (for fallback)
- SSH access for administration
---
## Installation Steps
### 1. Update System
```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl git ca-certificates gnupg
```
### 2. Install Docker Engine
```bash
# Add Docker's official GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Add the repository to Apt sources
echo \
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Verify installation
docker --version
docker compose version
```
### 3. Install GitLab Runner
```bash
# Add GitLab Runner repository
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
# Install GitLab Runner
sudo apt install gitlab-runner
# Verify installation
gitlab-runner --version
```
### 4. Register Runner with Shell Executor
```bash
sudo gitlab-runner register \
--non-interactive \
--url "https://git.motovaultpro.com" \
--registration-token "YOUR_REGISTRATION_TOKEN" \
--executor "shell" \
--description "Build Server - Shell Executor" \
--tag-list "build" \
--run-untagged="false" \
--locked="true"
```
**Notes:**
- Replace `YOUR_REGISTRATION_TOKEN` with the token from GitLab Admin > CI/CD > Runners
- Shell executor runs jobs directly on the host with access to Docker
- Tag `build` is used in `.gitlab-ci.yml` to route build jobs to this server
### 5. Add gitlab-runner to Docker Group
The gitlab-runner user needs access to Docker:
```bash
sudo usermod -aG docker gitlab-runner
# Verify access
sudo -u gitlab-runner docker info
sudo -u gitlab-runner docker compose version
```
### 6. Configure Docker Registry Authentication
Create credentials file for GitLab Container Registry:
```bash
# Login to GitLab Container Registry (creates ~/.docker/config.json)
docker login registry.motovaultpro.com -u <deploy-token-username> -p <deploy-token>
```
**Creating Deploy Token:**
1. Go to GitLab Project > Settings > Repository > Deploy Tokens
2. Create token with `read_registry` and `write_registry` scopes
3. Use the token username/password for Docker login
---
## Verification
### Test Runner Registration
```bash
sudo gitlab-runner verify
```
Expected output:
```
Verifying runner... is alive runner=XXXXXX
```
### Test Docker Access
```bash
sudo gitlab-runner exec docker --docker-privileged test-job
```
### Test Registry Push
```bash
# Build and push a test image
docker build -t registry.motovaultpro.com/motovaultpro/test:latest -f- . <<EOF
FROM alpine:latest
RUN echo "test"
EOF
docker push registry.motovaultpro.com/motovaultpro/test:latest
```
---
## Maintenance
### Disk Cleanup
Docker builds accumulate disk space. Set up automated cleanup:
```bash
# Create cleanup script
sudo tee /usr/local/bin/docker-cleanup.sh > /dev/null <<'EOF'
#!/bin/bash
# Remove unused Docker resources older than 7 days
docker system prune -af --filter "until=168h"
docker volume prune -f
EOF
sudo chmod +x /usr/local/bin/docker-cleanup.sh
# Add to crontab (run daily at 3 AM)
echo "0 3 * * * /usr/local/bin/docker-cleanup.sh >> /var/log/docker-cleanup.log 2>&1" | sudo crontab -
```
### Log Rotation
Configure log rotation for GitLab Runner:
```bash
sudo tee /etc/logrotate.d/gitlab-runner > /dev/null <<EOF
/var/log/gitlab-runner/*.log {
daily
rotate 7
compress
missingok
notifempty
}
EOF
```
### Update Runner
```bash
# Update GitLab Runner
sudo apt update
sudo apt upgrade gitlab-runner
# Restart runner
sudo gitlab-runner restart
```
---
## Security Considerations
### Firewall Configuration
```bash
# Allow only necessary outbound traffic
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw enable
```
### Runner Security
- **Locked runner**: Only accepts jobs from the specific project
- **Protected tags**: Only runs on protected branches (main)
- **Docker socket**: Mounted read-only where possible
### Secrets Management
The build server does NOT store application secrets. All secrets are:
- Stored in GitLab CI/CD Variables
- Injected at runtime on the production server
- Never cached in Docker layers
---
## Troubleshooting
### Runner Not Picking Up Jobs
```bash
# Check runner status
sudo gitlab-runner status
# View runner logs
sudo journalctl -u gitlab-runner -f
# Re-register runner if needed
sudo gitlab-runner unregister --all-runners
sudo gitlab-runner register
```
### Docker Build Failures
```bash
# Check Docker daemon
sudo systemctl status docker
# Check available disk space
df -h
# Clear Docker cache
docker system prune -af
```
### Registry Push Failures
```bash
# Verify registry login
docker login registry.motovaultpro.com
# Check network connectivity
curl -v https://registry.motovaultpro.com/v2/
# Verify image exists
docker images | grep motovaultpro
```
---
## Quick Reference
### Important Paths
| Path | Description |
|------|-------------|
| `/etc/gitlab-runner/config.toml` | Runner configuration |
| `/var/log/gitlab-runner/` | Runner logs |
| `~/.docker/config.json` | Docker registry credentials |
| `/var/lib/docker/` | Docker data |
### Common Commands
```bash
# Runner management
sudo gitlab-runner status
sudo gitlab-runner restart
sudo gitlab-runner verify
# Docker management
docker system df # Check disk usage
docker system prune -af # Clean all unused resources
docker images # List images
docker ps -a # List containers
# View build logs
sudo journalctl -u gitlab-runner --since "1 hour ago"
```

View File

@@ -1,17 +1,72 @@
# MotoVaultPro GitLab CI/CD Deployment Guide # MotoVaultPro GitLab CI/CD Deployment Guide
Complete guide for deploying MotoVaultPro using GitLab CI/CD with shell executor runners. Complete guide for deploying MotoVaultPro using GitLab CI/CD with blue-green deployment and auto-rollback.
## Table of Contents ## Table of Contents
1. [Prerequisites](#prerequisites) 1. [Architecture Overview](#architecture-overview)
2. [GitLab Runner Setup](#gitlab-runner-setup) 2. [Prerequisites](#prerequisites)
3. [CI/CD Variables Configuration](#cicd-variables-configuration) 3. [Pipeline Stages](#pipeline-stages)
4. [Secrets Architecture](#secrets-architecture) 4. [Blue-Green Deployment](#blue-green-deployment)
5. [Pipeline Overview](#pipeline-overview) 5. [CI/CD Variables Configuration](#cicd-variables-configuration)
6. [Deployment Process](#deployment-process) 6. [Container Registry](#container-registry)
7. [Rollback Procedure](#rollback-procedure) 7. [Deployment Process](#deployment-process)
8. [Troubleshooting](#troubleshooting) 8. [Rollback Procedures](#rollback-procedures)
9. [Maintenance Migrations](#maintenance-migrations)
10. [Notifications](#notifications)
11. [Troubleshooting](#troubleshooting)
---
## Architecture Overview
MotoVaultPro uses a blue-green deployment strategy with automatic rollback:
```
+---------------------------------------------------+
| GitLab (CI/CD + Registry) |
+---------------------------------------------------+
| |
v v
+------------------+ +-----------------------+
| Build VPS | | Production Server |
| (Docker Runner) | | (Shell Runner) |
| Tags: build | | Tags: production |
+------------------+ +-----------+-----------+
| |
| Push images | Pull + Deploy
v v
+---------------------------------------------------+
| GitLab Container Registry |
| registry.motovaultpro.com/motovaultpro/ |
+---------------------------------------------------+
|
+---------------+---------------+
| |
+--------v--------+ +--------v--------+
| BLUE Stack | | GREEN Stack |
| mvp-frontend | | mvp-frontend |
| mvp-backend | | mvp-backend |
+-----------------+ +-----------------+
| |
+----------- Traefik -----------+
(weighted LB)
|
+---------------+---------------+
| |
+--------v--------+ +--------v--------+
| PostgreSQL | | Redis |
| (shared) | | (shared) |
+-----------------+ +-----------------+
```
### Key Features
- **Zero-downtime deployments**: Traffic switches in under 5 seconds
- **Instant rollback**: Previous version remains running
- **Automatic rollback**: On health check failure
- **Email notifications**: Via Resend API
- **Container registry**: Self-hosted on GitLab (no Docker Hub)
--- ---
@@ -19,55 +74,115 @@ Complete guide for deploying MotoVaultPro using GitLab CI/CD with shell executor
### Server Requirements ### Server Requirements
- Linux server with Docker Engine installed | Server | Purpose | Specs | Runner Tags |
- Docker Compose v2 (plugin version) |--------|---------|-------|-------------|
- GitLab Runner installed and registered | Build VPS | Docker image builds | 2 CPU, 4GB RAM | `build` |
- Git installed | Prod Server | Application hosting | 8GB+ RAM | `production` |
- curl installed (for health checks)
### GitLab Requirements See [BUILD-SERVER-SETUP.md](BUILD-SERVER-SETUP.md) for build server setup.
- GitLab 18.6+ (tested with 18.6.2) ### Software Requirements
- Project with CI/CD enabled
- Protected `main` branch - GitLab 18.6+
- Maintainer access for CI/CD variable configuration - Docker Engine 24.0+
- Docker Compose v2
- GitLab Runner (shell executor on both servers)
- `jq` for JSON processing
--- ---
## GitLab Runner Setup ## Pipeline Stages
### 1. Verify Runner Registration The CI/CD pipeline consists of 7 stages:
```bash ```
sudo gitlab-runner verify validate -> build -> deploy-prepare -> deploy-switch -> verify -> [rollback] -> notify
``` ```
Expected output should show your runner as active with shell executor. | Stage | Runner | Purpose |
|-------|--------|---------|
| `validate` | prod | Check prerequisites, determine target stack |
| `build` | build | Build and push images to GitLab registry |
| `deploy-prepare` | prod | Pull images, start inactive stack, health check |
| `deploy-switch` | prod | Switch Traefik traffic weights |
| `verify` | prod | Production health verification |
| `rollback` | prod | Auto-triggered on verify failure |
| `notify` | prod | Email success/failure notifications |
### 2. Verify Docker Permissions ### Pipeline Flow
The `gitlab-runner` user must have Docker access: ```
[Push to main]
```bash |
# Add gitlab-runner to docker group (if not already done) v
sudo usermod -aG docker gitlab-runner [validate] - Checks Docker, paths, registry
|
# Verify access v
sudo -u gitlab-runner docker info [build] - Builds backend + frontend images
sudo -u gitlab-runner docker compose version | Pushes to registry.motovaultpro.com
v
[deploy-prepare] - Pulls new images
| Starts inactive stack (blue or green)
| Runs health checks
v
[deploy-switch] - Updates Traefik weights
| Switches traffic instantly
v
[verify] - External health check
| Container status verification
|
+--[SUCCESS]--> [notify-success] - Sends success email
|
+--[FAILURE]--> [rollback] - Switches back to previous stack
|
v
[notify-failure] - Sends failure email
``` ```
### 3. Verify Deployment Directory ---
Ensure the deployment directory exists and is accessible: ## Blue-Green Deployment
```bash ### Stack Configuration
# Create deployment directory
sudo mkdir -p /opt/motovaultpro
sudo chown gitlab-runner:gitlab-runner /opt/motovaultpro
# Clone repository (first time only) Both stacks share the same database layer:
sudo -u gitlab-runner git clone <repository-url> /opt/motovaultpro
| Component | Blue Stack | Green Stack | Shared |
|-----------|------------|-------------|--------|
| Frontend | `mvp-frontend-blue` | `mvp-frontend-green` | - |
| Backend | `mvp-backend-blue` | `mvp-backend-green` | - |
| PostgreSQL | - | - | `mvp-postgres` |
| Redis | - | - | `mvp-redis` |
| Traefik | - | - | `mvp-traefik` |
### Traffic Routing
Traefik uses weighted services for traffic distribution:
```yaml
# config/traefik/dynamic/blue-green.yml
services:
mvp-frontend-weighted:
weighted:
services:
- name: mvp-frontend-blue-svc
weight: 100 # Active
- name: mvp-frontend-green-svc
weight: 0 # Standby
```
### Deployment State
State is tracked in `config/deployment/state.json`:
```json
{
"active_stack": "blue",
"inactive_stack": "green",
"last_deployment": "2024-01-15T10:30:00Z",
"last_deployment_commit": "abc123",
"rollback_available": true
}
``` ```
--- ---
@@ -76,410 +191,316 @@ sudo -u gitlab-runner git clone <repository-url> /opt/motovaultpro
Navigate to **Settings > CI/CD > Variables** in your GitLab project. Navigate to **Settings > CI/CD > Variables** in your GitLab project.
### Secrets (File Type Variables) ### Required Variables
These variables use GitLab's **File** type, which writes the value to a temporary file and provides the path as the environment variable. This replicates the Kubernetes secrets pattern used by the application. | Variable | Type | Protected | Purpose |
|----------|------|-----------|---------|
| Variable Name | Type | Protected | Masked | Description | | `DEPLOY_NOTIFY_EMAIL` | Variable | Yes | Notification recipient |
|--------------|------|-----------|--------|-------------| | `VITE_AUTH0_DOMAIN` | Variable | No | Auth0 domain |
| `POSTGRES_PASSWORD` | File | Yes | Yes | PostgreSQL database password | | `VITE_AUTH0_CLIENT_ID` | Variable | No | Auth0 client ID |
| `AUTH0_CLIENT_SECRET` | File | Yes | Yes | Auth0 client secret for backend | | `VITE_AUTH0_AUDIENCE` | Variable | No | Auth0 audience |
| `GOOGLE_MAPS_API_KEY` | File | Yes | Yes | Google Maps API key |
| `GOOGLE_MAPS_MAP_ID` | File | Yes | No | Google Maps Map ID |
| `CF_DNS_API_TOKEN` | File | Yes | Yes | Cloudflare API token for Let's Encrypt DNS challenge |
| `RESEND_API_KEY` | File | Yes | Yes | Resend API key for email notifications |
### Configuration Variables
| Variable Name | Type | Protected | Masked | Value |
|--------------|------|-----------|--------|-------|
| `VITE_AUTH0_DOMAIN` | Variable | No | No | `motovaultpro.us.auth0.com` |
| `VITE_AUTH0_CLIENT_ID` | Variable | No | No | Your Auth0 client ID |
| `VITE_AUTH0_AUDIENCE` | Variable | No | No | `https://api.motovaultpro.com` |
Note: `DEPLOY_PATH` is automatically set in `.gitlab-ci.yml` using `GIT_CLONE_PATH` for a stable path.
### Creating Cloudflare API Token
The `CF_DNS_API_TOKEN` is required for automatic SSL certificate generation via Let's Encrypt DNS-01 challenge.
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/profile/api-tokens)
2. Click **Create Token**
3. Use template: **Edit zone DNS**
4. Configure permissions:
- **Permissions**: Zone > DNS > Edit
- **Zone Resources**: Include > Specific zone > `motovaultpro.com`
5. Click **Continue to summary** then **Create Token**
6. Copy the token value immediately (it won't be shown again)
7. Add as `CF_DNS_API_TOKEN` File variable in GitLab
### Setting Up a File Type Variable
1. Go to **Settings > CI/CD > Variables**
2. Click **Add variable**
3. Enter the variable key (e.g., `POSTGRES_PASSWORD`)
4. Enter the secret value in the **Value** field
5. Set **Type** to **File**
6. Enable **Protect variable** (recommended)
7. Enable **Mask variable** (for sensitive data)
8. Click **Add variable**
---
## Secrets Architecture
MotoVaultPro uses a Kubernetes-style secrets pattern where secrets are mounted as files at `/run/secrets/` inside containers.
### How It Works
1. **GitLab stores secrets** as File type CI/CD variables
2. **During pipeline execution**, GitLab writes each secret to a temporary file
3. **The `inject-secrets.sh` script** copies these files to `secrets/app/` directory
4. **Docker Compose** mounts these files to `/run/secrets/` in containers
5. **Application code** reads secrets from the filesystem (not environment variables)
### Secret Files ### Secret Files
``` These use GitLab's **File** type and are injected via `scripts/inject-secrets.sh`:
secrets/app/
postgres-password.txt -> /run/secrets/postgres-password
auth0-client-secret.txt -> /run/secrets/auth0-client-secret
google-maps-api-key.txt -> /run/secrets/google-maps-api-key
google-maps-map-id.txt -> /run/secrets/google-maps-map-id
cloudflare-dns-token.txt -> /run/secrets/cloudflare-dns-token
resend-api-key.txt -> /run/secrets/resend-api-key
```
### Security Benefits | Variable | Type | Protected | Masked |
|----------|------|-----------|--------|
| `POSTGRES_PASSWORD` | File | Yes | Yes |
| `AUTH0_CLIENT_SECRET` | File | Yes | Yes |
| `GOOGLE_MAPS_API_KEY` | File | Yes | Yes |
| `GOOGLE_MAPS_MAP_ID` | File | Yes | No |
| `CF_DNS_API_TOKEN` | File | Yes | Yes |
| `RESEND_API_KEY` | File | Yes | Yes |
- Secrets never appear as environment variables (not visible in `env` or `printenv`) ### Registry Authentication
- File permissions (600) restrict access
- Masked variables prevent accidental log exposure GitLab provides these automatically:
- Protected variables only available on protected branches - `CI_REGISTRY_USER` - Registry username
- `CI_REGISTRY_PASSWORD` - Registry token
- `CI_REGISTRY` - Registry URL
--- ---
## Pipeline Overview ## Container Registry
The CI/CD pipeline consists of four stages: All images are hosted on the GitLab Container Registry to avoid Docker Hub rate limits.
### Stage 1: Validate ### Registry URL
Verifies deployment prerequisites:
- Docker is accessible
- Docker Compose is available
- Deployment directory exists
### Stage 2: Build
Builds Docker images:
- Pulls latest code from repository
- Builds all service images with `--no-cache`
### Stage 3: Deploy
Deploys the application:
1. Injects secrets from GitLab variables
2. Stops existing services gracefully
3. Pulls base images
4. Starts database services (PostgreSQL, Redis)
5. Runs database migrations
6. Starts all services
### Stage 4: Verify
Validates deployment health:
- Checks all containers are running
- Tests backend health endpoint
- Reports deployment status
### Pipeline Diagram
``` ```
[Validate] -> [Build] -> [Deploy] -> [Verify] registry.motovaultpro.com
| | | |
Check Build Inject Health
prereqs images secrets checks
|
Migrate
|
Start
services
``` ```
### Image Paths
| Image | Path |
|-------|------|
| Backend | `registry.motovaultpro.com/motovaultpro/backend:$TAG` |
| Frontend | `registry.motovaultpro.com/motovaultpro/frontend:$TAG` |
| Mirrors | `registry.motovaultpro.com/mirrors/` |
### Base Image Mirrors
Mirror upstream images to avoid rate limits:
```bash
# Run manually or via scheduled pipeline
./scripts/ci/mirror-base-images.sh
```
Mirrored images:
- `node:20-alpine`
- `nginx:alpine`
- `postgres:18-alpine`
- `redis:8.4-alpine`
- `traefik:v3.6`
- `docker:24.0`
- `docker:24.0-dind`
--- ---
## Deployment Process ## Deployment Process
### Automatic Deployment ### Automatic Deployment
Deployments are triggered automatically when: Deployments trigger automatically on push to `main`:
- Code is pushed to the `main` branch
- A merge request is merged into `main` 1. **Validate**: Check prerequisites, determine target stack
2. **Build**: Build images on dedicated build server
3. **Prepare**: Start inactive stack, run health checks
4. **Switch**: Update Traefik weights (instant)
5. **Verify**: External health check
6. **Notify**: Send email notification
### Manual Deployment ### Manual Deployment
To trigger a manual deployment:
1. Go to **CI/CD > Pipelines** 1. Go to **CI/CD > Pipelines**
2. Click **Run pipeline** 2. Click **Run pipeline**
3. Select the `main` branch 3. Select `main` branch
4. Click **Run pipeline** 4. Click **Run pipeline**
### Deployment Steps (What Happens) ### Deployment Timeline
1. **Secrets Injection** | Phase | Duration |
- `inject-secrets.sh` copies GitLab File variables to `secrets/app/` |-------|----------|
- Permissions are set to 600 for security | Validate | ~5s |
| Build | ~2 min |
2. **Service Shutdown** | Deploy-prepare | ~30s |
- Existing containers are stopped gracefully (30s timeout) | Deploy-switch | ~3s |
- Volumes are preserved | Verify | ~30s |
| **Total** | ~3 min |
3. **Database Startup**
- PostgreSQL and Redis start first
- 15-second wait for database readiness
4. **Migrations**
- Backend container runs database migrations
- Ensures schema is up-to-date
5. **Full Service Startup**
- All services start via `docker compose up -d`
- Traefik routes traffic automatically
6. **Health Verification**
- Container status checks
- Backend health endpoint validation
--- ---
## Rollback Procedure ## Rollback Procedures
### Automatic Rollback ### Automatic Rollback
If the verify stage fails, the pipeline will report failure but services remain running. Manual intervention is required. Triggers automatically when:
- Health check fails in `deploy-prepare`
- `verify` stage fails after switch
- Container becomes unhealthy within verification period
The pipeline runs `scripts/ci/auto-rollback.sh` which:
1. Verifies previous stack is healthy
2. Switches traffic back
3. Sends notification
### Manual Rollback ### Manual Rollback
Use the rollback script: SSH to production server:
```bash ```bash
# SSH to server
ssh user@server
# Run rollback to previous commit
cd /opt/motovaultpro cd /opt/motovaultpro
./scripts/rollback.sh HEAD~1
# Or rollback to specific tag/commit # Check current state
./scripts/rollback.sh v1.0.0 cat config/deployment/state.json | jq .
# Switch to other stack
./scripts/ci/switch-traffic.sh blue # or green
``` ```
### Rollback Script Details
The script performs:
1. Stops all current services
2. Checks out the specified version
3. Rebuilds images
4. Starts services
### Emergency Recovery ### Emergency Recovery
If rollback fails: If both stacks are unhealthy:
```bash
# Stop everything
docker compose -f docker-compose.yml -f docker-compose.blue-green.yml down
# Restart shared services
docker compose up -d mvp-postgres mvp-redis mvp-traefik
# Wait for database
sleep 15
# Start one stack
export BACKEND_IMAGE=registry.motovaultpro.com/motovaultpro/backend:latest
export FRONTEND_IMAGE=registry.motovaultpro.com/motovaultpro/frontend:latest
docker compose -f docker-compose.yml -f docker-compose.blue-green.yml up -d \
mvp-frontend-blue mvp-backend-blue
# Switch traffic
./scripts/ci/switch-traffic.sh blue
```
---
## Maintenance Migrations
For breaking database changes requiring downtime:
### Via Pipeline (Recommended)
1. Go to **CI/CD > Pipelines**
2. Find the `maintenance-migration` job
3. Click **Play** to trigger manually
### Via Script
```bash ```bash
cd /opt/motovaultpro cd /opt/motovaultpro
# Stop everything # With backup
docker compose down ./scripts/ci/maintenance-migrate.sh backup
# Check git history # Without backup
git log --oneline -10 ./scripts/ci/maintenance-migrate.sh
```
# Checkout known working version ### What Happens
git checkout <commit-hash>
# Rebuild and start 1. Sends maintenance notification
docker compose build 2. Enables maintenance mode (stops traffic)
docker compose up -d 3. Creates database backup (if requested)
4. Runs migrations
5. Restarts backends
6. Restores traffic
7. Sends completion notification
# Verify ---
docker compose ps
## Notifications
Email notifications via Resend API for:
| Event | Subject |
|-------|---------|
| `success` | Deployment Successful |
| `failure` | Deployment Failed |
| `rollback` | Auto-Rollback Executed |
| `rollback_failed` | CRITICAL: Rollback Failed |
| `maintenance_start` | Maintenance Mode Started |
| `maintenance_end` | Maintenance Complete |
Configure recipient in GitLab CI/CD variables:
```
DEPLOY_NOTIFY_EMAIL = admin@example.com
``` ```
--- ---
## Troubleshooting ## Troubleshooting
### Pipeline Fails at Validate Stage
**Symptom**: `DEPLOY_PATH not found`
**Solution**:
```bash
# Create directory on runner server
sudo mkdir -p /opt/motovaultpro
sudo chown gitlab-runner:gitlab-runner /opt/motovaultpro
```
### Pipeline Fails at Build Stage ### Pipeline Fails at Build Stage
**Symptom**: Docker build errors **Check build server connectivity:**
**Solutions**:
1. Check Dockerfile syntax
2. Verify network connectivity for npm/package downloads
3. Check disk space: `df -h`
4. Clear Docker cache: `docker system prune -a`
### Pipeline Fails at Deploy Stage
**Symptom**: Secrets injection fails
**Solutions**:
1. Verify CI/CD variables are configured correctly
2. Check variable types are set to **File** for secrets
3. Ensure variables are not restricted to specific environments
**Symptom**: Migration fails
**Solutions**:
1. Check database connectivity
2. Verify PostgreSQL is healthy: `docker logs mvp-postgres`
3. Run migrations manually:
```bash
docker compose exec mvp-backend npm run migrate
```
### Pipeline Fails at Verify Stage
**Symptom**: Container not running
**Solutions**:
1. Check container logs: `docker logs <container-name>`
2. Verify secrets are correctly mounted
3. Check for port conflicts
**Symptom**: Health check fails
**Solutions**:
1. Wait longer (service might be starting)
2. Check backend logs: `docker logs mvp-backend`
3. Verify database connection
### Services Start But Application Doesn't Work
**Check secrets are mounted**:
```bash ```bash
docker compose exec mvp-backend ls -la /run/secrets/ # On build server
sudo gitlab-runner verify
docker login registry.motovaultpro.com
``` ```
**Check configuration**: **Check disk space:**
```bash ```bash
docker compose exec mvp-backend cat /app/config/production.yml df -h
docker system prune -af
``` ```
**Check network connectivity**: ### Pipeline Fails at Deploy-Prepare
**Container won't start:**
```bash ```bash
docker network ls docker logs mvp-backend-blue --tail 100
docker network inspect motovaultpro_backend docker logs mvp-frontend-blue --tail 100
``` ```
### Viewing Logs **Health check timeout:**
```bash ```bash
# All services # Increase timeout in .gitlab-ci.yml
docker compose logs -f HEALTH_CHECK_TIMEOUT: "90"
# Specific service
docker compose logs -f mvp-backend
# Last 100 lines
docker compose logs --tail 100 mvp-backend
``` ```
--- ### Traffic Not Switching
## Maintenance
### Updating Secrets
1. Update the CI/CD variable in GitLab
2. Trigger a new pipeline (push or manual)
3. The new secrets will be injected during deployment
### Database Backups
Backups should be configured separately. Recommended approach:
**Check Traefik config:**
```bash ```bash
# Manual backup cat config/traefik/dynamic/blue-green.yml
docker compose exec mvp-postgres pg_dump -U postgres motovaultpro > backup.sql docker exec mvp-traefik traefik healthcheck
# Automated backup (add to cron)
0 2 * * * cd /opt/motovaultpro && docker compose exec -T mvp-postgres pg_dump -U postgres motovaultpro > /backups/mvp-$(date +\%Y\%m\%d).sql
``` ```
### Monitoring **Check routing:**
```bash
curl -I https://motovaultpro.com/api/health
```
Consider adding: ### Verify Stage Fails
- Prometheus metrics (Traefik already configured)
- Health check alerts **Check external connectivity:**
- Log aggregation ```bash
curl -sf https://motovaultpro.com/api/health
```
**Check container health:**
```bash
docker inspect --format='{{.State.Health.Status}}' mvp-backend-blue
```
--- ---
## Quick Reference ## Quick Reference
### Common Commands
```bash
# View pipeline status
# GitLab UI: CI/CD > Pipelines
# SSH to server
ssh user@your-server
# Navigate to project
cd /opt/motovaultpro
# View running containers
docker compose ps
# View logs
docker compose logs -f
# Restart a service
docker compose restart mvp-backend
# Run migrations manually
docker compose exec mvp-backend npm run migrate
# Access database
docker compose exec mvp-postgres psql -U postgres motovaultpro
# Health check
curl http://localhost:3001/health
```
### Important Paths ### Important Paths
| Path | Description | | Path | Description |
|------|-------------| |------|-------------|
| `$CI_BUILDS_DIR/motovaultpro` | Application root (stable clone path) | | `config/deployment/state.json` | Deployment state |
| `$CI_BUILDS_DIR/motovaultpro/secrets/app/` | Secrets directory | | `config/traefik/dynamic/blue-green.yml` | Traffic routing |
| `$CI_BUILDS_DIR/motovaultpro/data/documents/` | Document storage | | `scripts/ci/` | Deployment scripts |
| `$CI_BUILDS_DIR/motovaultpro/config/` | Configuration files |
Note: `CI_BUILDS_DIR` is typically `/opt/gitlab-runner/builds` for shell executors. ### Common Commands
### Container Names ```bash
# View current state
cat config/deployment/state.json | jq .
| Container | Purpose | # Check container status
|-----------|---------| docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Health}}"
| `mvp-traefik` | Reverse proxy, TLS termination |
| `mvp-frontend` | React SPA | # View logs
| `mvp-backend` | Node.js API | docker logs mvp-backend-blue -f
| `mvp-postgres` | PostgreSQL database |
| `mvp-redis` | Redis cache | # Manual traffic switch
./scripts/ci/switch-traffic.sh green
# Run health check
./scripts/ci/health-check.sh blue
# Send test notification
./scripts/ci/notify.sh success "Test message"
```
### Memory Budget (8GB Server)
| Component | RAM |
|-----------|-----|
| Blue frontend | 512MB |
| Blue backend | 1GB |
| Green frontend | 512MB |
| Green backend | 1GB |
| PostgreSQL | 2GB |
| Redis | 512MB |
| Traefik | 128MB |
| System | 1.3GB |
| **Total** | ~7GB |

View File

@@ -22,13 +22,16 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
- Make no assumptions. - Make no assumptions.
- Ask clarifying questions. - Ask clarifying questions.
- Ultrathink - Ultrathink
- You will be fixing a bug with the application backup function. - You will be fixing a bug the system backup and restore function.
*** CONTEXT *** *** CONTEXT ***
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s. - This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change. - Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
- There is an error when you try and create a backup. - There are permission errors with the backup files.
- Start with this file. /Users/egullickson/Documents/Technology/coding/motovaultpro/backend/src/features/admin/backup/api/backup.controller.ts - The backup directory is mapped from the filesystem of the host
- The app is deployed as the gitlab-runner user and group which is a different UID then the nodejs user
- Start with the files in this directory /Users/egullickson/Documents/Technology/coding/motovaultpro/backend/src/features/backup/api
- The docker file is located at /Users/egullickson/Documents/Technology/coding/motovaultpro/backend/Dockerfile
*** CHANGES TO IMPLEMENT *** *** CHANGES TO IMPLEMENT ***
- Research this code base and ask iterative questions to compile a complete plan. - Research this code base and ask iterative questions to compile a complete plan.
@@ -45,16 +48,19 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
- Make no assumptions. - Make no assumptions.
- Ask clarifying questions. - Ask clarifying questions.
- Ultrathink - Ultrathink
- The initial data load for this applicaiton during the CI/CD process in gitlab needs to be updated - This application is ready to go into production.
- Analysis needs to be done on the CI/CD pipeline
*** CONTEXT *** *** CONTEXT ***
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change. - Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
- The current deployment database load needs to be thoroughly critiqued and scrutenized. - The current deployment does not take into account no downtime or miniimal downtime updates.
- The same runner's build the software that run the software
- There needs to be a balance of uptime and complexity
- production will run on a single server to start
*** ACTION - CHANGES TO IMPLEMENT *** *** ACTION - CHANGES TO IMPLEMENT ***
- The vehicle catalog currently loaded into the local mvp-postres container needs to be exported into a SQL file and saved as a source of truth - Research this code base and ask iterative questions to compile a complete plan.
- Whatever process is running today only goes up to model year 2022. - We will pair plan this. Ask me for options for various levels of redundancy and automation
- All the existing SQL files setup for import can be replaced with new ones created from the running mvp-postres data.
@@ -97,14 +103,17 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
- Make no assumptions. - Make no assumptions.
- Ask clarifying questions. - Ask clarifying questions.
- Ultrathink - Ultrathink
- You will be making changes to the color theme of this application. - You will be making changes to email templates of this application.
*** CONTEXT *** *** CONTEXT ***
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s. - This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change. - Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
- Currently the onboarding drop downs are washed out when using the light theme. See image. - Start your research at this route https://motovaultpro.com/garage/settings/admin/email-templates
- The colors need to change to have more contrast but retain the MUI theme for drop down. - The email templates are currently plain text.
- The templates need to be improved with colors and the company logo
- The company log should be base64 encoded in the email so end users don't need to download anything.
- The theme should match the website light theme
- A screenshot showing the colors is attached
*** CHANGES TO IMPLEMENT *** *** CHANGES TO IMPLEMENT ***
- Research this code base and ask iterative questions to compile a complete plan. - Research this code base and ask iterative questions to compile a complete plan.
- The URL is here. https://motovaultpro.com/onboarding

View File

@@ -1,7 +1,11 @@
# Production Dockerfile for MotoVaultPro Frontend # Production Dockerfile for MotoVaultPro Frontend
# Uses mirrored base images from GitLab Container Registry
# Build argument for registry (defaults to GitLab mirrors, falls back to Docker Hub)
ARG REGISTRY_MIRRORS=registry.motovaultpro.com/mirrors
# Stage 1: Base with dependencies # Stage 1: Base with dependencies
FROM node:lts-alpine AS base FROM ${REGISTRY_MIRRORS}/node:20-alpine AS base
RUN apk add --no-cache dumb-init curl RUN apk add --no-cache dumb-init curl
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
@@ -29,7 +33,7 @@ COPY . .
RUN npm run build RUN npm run build
# Stage 4: Production stage with nginx # Stage 4: Production stage with nginx
FROM nginx:alpine AS production FROM ${REGISTRY_MIRRORS}/nginx:alpine AS production
# Add curl for healthchecks # Add curl for healthchecks
RUN apk add --no-cache curl RUN apk add --no-cache curl

View File

@@ -4,7 +4,36 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark">
<title>MotoVaultPro</title> <title>MotoVaultPro</title>
<!-- Dark mode initialization - MUST run before any other scripts -->
<!-- This prevents iOS 26 Safari from overriding our dark mode preference -->
<script>
(function() {
try {
const stored = localStorage.getItem('motovaultpro-mobile-settings');
const settings = stored ? JSON.parse(stored) : {};
// Check user preference, fall back to system preference
const prefersDark = settings.darkMode !== undefined
? settings.darkMode
: window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {
// Fallback to system preference on error
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
}
})();
</script>
<!-- Runtime config MUST load synchronously before any module scripts --> <!-- Runtime config MUST load synchronously before any module scripts -->
<script src="/config.js"></script> <script src="/config.js"></script>
</head> </head>

View File

@@ -5,7 +5,7 @@
import React from 'react'; import React from 'react';
import { useAuth0 } from '@auth0/auth0-react'; import { useAuth0 } from '@auth0/auth0-react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { Container, Paper, Typography, Box, IconButton, Avatar, useTheme } from '@mui/material'; import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
@@ -28,7 +28,6 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
const { user, logout } = useAuth0(); const { user, logout } = useAuth0();
const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore(); const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore();
const location = useLocation(); const location = useLocation();
const theme = useTheme();
// Sync theme preference with backend // Sync theme preference with backend
useThemeSync(); useThemeSync();
@@ -128,14 +127,17 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
}} }}
> >
<Box <Box
sx={{ sx={(theme) => ({
backgroundColor: theme.palette.mode === 'light' ? 'primary.main' : 'transparent', backgroundColor: 'primary.main',
...theme.applyStyles('dark', {
backgroundColor: 'transparent',
}),
borderRadius: 1, borderRadius: 1,
px: 1, px: 1,
py: 0.5, py: 0.5,
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center' alignItems: 'center'
}} })}
> >
<img <img
src="/images/logos/motovaultpro-title-slogan.png" src="/images/logos/motovaultpro-title-slogan.png"

View File

@@ -30,6 +30,7 @@ import {
CascadeDeleteResult, CascadeDeleteResult,
EmailTemplate, EmailTemplate,
UpdateEmailTemplateRequest, UpdateEmailTemplateRequest,
PreviewTemplateResponse,
// User management types // User management types
ManagedUser, ManagedUser,
ListUsersResponse, ListUsersResponse,
@@ -278,15 +279,15 @@ export const adminApi = {
const response = await apiClient.put<EmailTemplate>(`/admin/email-templates/${key}`, data); const response = await apiClient.put<EmailTemplate>(`/admin/email-templates/${key}`, data);
return response.data; return response.data;
}, },
preview: async (key: string, variables: Record<string, string>): Promise<{ subject: string; body: string }> => { preview: async (key: string, variables: Record<string, string>): Promise<PreviewTemplateResponse> => {
const response = await apiClient.post<{ subject: string; body: string }>( const response = await apiClient.post<PreviewTemplateResponse>(
`/admin/email-templates/${key}/preview`, `/admin/email-templates/${key}/preview`,
{ variables } { variables }
); );
return response.data; return response.data;
}, },
sendTest: async (key: string): Promise<{ message?: string; error?: string; subject: string; body: string }> => { sendTest: async (key: string): Promise<{ message?: string; error?: string }> => {
const response = await apiClient.post<{ message?: string; error?: string; subject: string; body: string }>( const response = await apiClient.post<{ message?: string; error?: string }>(
`/admin/email-templates/${key}/test` `/admin/email-templates/${key}/test`
); );
return response.data; return response.data;

View File

@@ -347,11 +347,10 @@ export const useImportApply = () => {
return useMutation({ return useMutation({
mutationFn: (previewId: string) => adminApi.importApply(previewId), mutationFn: (previewId: string) => adminApi.importApply(previewId),
onSuccess: (result) => { onSuccess: () => {
// Invalidate cache to refresh catalog data
queryClient.invalidateQueries({ queryKey: ['catalogSearch'] }); queryClient.invalidateQueries({ queryKey: ['catalogSearch'] });
toast.success( // Note: Toast and dialog behavior now handled by parent components
`Import completed: ${result.created} created, ${result.updated} updated`
);
}, },
onError: (error: ApiError) => { onError: (error: ApiError) => {
toast.error(error.response?.data?.error || 'Failed to apply import'); toast.error(error.response?.data?.error || 'Failed to apply import');

View File

@@ -610,7 +610,7 @@ export const AdminBackupMobileScreen: React.FC = () => {
{showRestoreConfirm && ( {showRestoreConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-sm w-full"> <div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-slate-800 mb-4">Confirm Restore</h3> <h3 className="text-lg font-semibold text-slate-800 mb-4">Confirm Restore</h3>
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4"> <div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
<p className="text-xs text-red-800"> <p className="text-xs text-red-800">
@@ -988,7 +988,7 @@ export const AdminBackupMobileScreen: React.FC = () => {
{/* Delete Backup Confirmation Modal */} {/* Delete Backup Confirmation Modal */}
{showDeleteBackupConfirm && ( {showDeleteBackupConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-sm w-full"> <div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-slate-800 mb-4">Delete Backup</h3> <h3 className="text-lg font-semibold text-slate-800 mb-4">Delete Backup</h3>
<p className="text-sm text-slate-600 mb-4"> <p className="text-sm text-slate-600 mb-4">
Are you sure you want to delete "{selectedBackup?.filename}"? This action cannot be Are you sure you want to delete "{selectedBackup?.filename}"? This action cannot be
@@ -1016,7 +1016,7 @@ export const AdminBackupMobileScreen: React.FC = () => {
{/* Delete Schedule Confirmation Modal */} {/* Delete Schedule Confirmation Modal */}
{showDeleteScheduleConfirm && ( {showDeleteScheduleConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-sm w-full"> <div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-slate-800 mb-4">Delete Schedule</h3> <h3 className="text-lg font-semibold text-slate-800 mb-4">Delete Schedule</h3>
<p className="text-sm text-slate-600 mb-4"> <p className="text-sm text-slate-600 mb-4">
Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be

View File

@@ -13,6 +13,8 @@ import {
MoreVert, MoreVert,
Close, Close,
History, History,
ExpandMore,
ExpandLess,
} from '@mui/icons-material'; } from '@mui/icons-material';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useAdminAccess } from '../../../core/auth/useAdminAccess'; import { useAdminAccess } from '../../../core/auth/useAdminAccess';
@@ -29,6 +31,7 @@ import { adminApi } from '../api/admin.api';
import { import {
CatalogSearchResult, CatalogSearchResult,
ImportPreviewResult, ImportPreviewResult,
ImportApplyResult,
} from '../types/admin.types'; } from '../types/admin.types';
export const AdminCatalogMobileScreen: React.FC = () => { export const AdminCatalogMobileScreen: React.FC = () => {
@@ -54,6 +57,8 @@ export const AdminCatalogMobileScreen: React.FC = () => {
// Import state // Import state
const [importSheet, setImportSheet] = useState(false); const [importSheet, setImportSheet] = useState(false);
const [importPreview, setImportPreview] = useState<ImportPreviewResult | null>(null); const [importPreview, setImportPreview] = useState<ImportPreviewResult | null>(null);
const [importResult, setImportResult] = useState<ImportApplyResult | null>(null);
const [errorsExpanded, setErrorsExpanded] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Hooks // Hooks
@@ -144,15 +149,38 @@ export const AdminCatalogMobileScreen: React.FC = () => {
if (!importPreview?.previewId) return; if (!importPreview?.previewId) return;
try { try {
await importApplyMutation.mutateAsync(importPreview.previewId); const result = await importApplyMutation.mutateAsync(importPreview.previewId);
setImportSheet(false); setImportResult(result);
setImportPreview(null);
if (result.errors.length > 0) {
toast.error(
`Import completed with ${result.errors.length} error(s): ${result.created} created, ${result.updated} updated`
);
// Keep sheet open for error review
} else {
toast.success(
`Import completed successfully: ${result.created} created, ${result.updated} updated`
);
// Auto-close on complete success
setImportSheet(false);
setImportPreview(null);
setImportResult(null);
}
refetch(); refetch();
} catch { } catch {
// Error handled by mutation // Error handled by mutation's onError
} }
}, [importPreview, importApplyMutation, refetch]); }, [importPreview, importApplyMutation, refetch]);
const handleImportSheetClose = useCallback(() => {
if (importApplyMutation.isPending) return;
setImportSheet(false);
setImportPreview(null);
setImportResult(null);
setErrorsExpanded(false);
}, [importApplyMutation.isPending]);
// Export handler // Export handler
const handleExport = useCallback(() => { const handleExport = useCallback(() => {
setMenuOpen(false); setMenuOpen(false);
@@ -351,14 +379,14 @@ export const AdminCatalogMobileScreen: React.FC = () => {
{menuOpen && ( {menuOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center"> <div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center">
<div <div
className="bg-white rounded-t-2xl w-full max-w-lg p-4 space-y-2 animate-slide-up" className="bg-white dark:bg-scuro rounded-t-2xl w-full max-w-lg p-4 space-y-2 animate-slide-up"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-slate-800">Options</h2> <h2 className="text-lg font-semibold text-slate-800 dark:text-avus">Options</h2>
<button <button
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
className="p-2 text-slate-500 hover:text-slate-700" className="p-2 text-slate-500 dark:text-titanio hover:text-slate-700 dark:hover:text-avus"
style={{ minHeight: '44px', minWidth: '44px' }} style={{ minHeight: '44px', minWidth: '44px' }}
> >
<Close /> <Close />
@@ -368,7 +396,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
<button <button
onClick={handleImportClick} onClick={handleImportClick}
disabled={importPreviewMutation.isPending} disabled={importPreviewMutation.isPending}
className="w-full flex items-center gap-3 px-4 py-3 text-left text-slate-700 hover:bg-slate-50 rounded-lg transition" className="w-full flex items-center gap-3 px-4 py-3 text-left text-slate-700 dark:text-avus hover:bg-slate-50 dark:hover:bg-gray-800 rounded-lg transition"
style={{ minHeight: '44px' }} style={{ minHeight: '44px' }}
> >
<FileUpload /> <FileUpload />
@@ -378,7 +406,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
<button <button
onClick={handleExport} onClick={handleExport}
disabled={exportMutation.isPending} disabled={exportMutation.isPending}
className="w-full flex items-center gap-3 px-4 py-3 text-left text-slate-700 hover:bg-slate-50 rounded-lg transition" className="w-full flex items-center gap-3 px-4 py-3 text-left text-slate-700 dark:text-avus hover:bg-slate-50 dark:hover:bg-gray-800 rounded-lg transition"
style={{ minHeight: '44px' }} style={{ minHeight: '44px' }}
> >
<FileDownload /> <FileDownload />
@@ -387,7 +415,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
<button <button
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
className="w-full bg-slate-100 text-slate-700 py-3 rounded-lg font-medium hover:bg-slate-200 transition mt-4" className="w-full bg-slate-100 dark:bg-gray-700 text-slate-700 dark:text-gray-200 py-3 rounded-lg font-medium hover:bg-slate-200 dark:hover:bg-gray-600 transition mt-4"
style={{ minHeight: '44px' }} style={{ minHeight: '44px' }}
> >
Cancel Cancel
@@ -399,9 +427,9 @@ export const AdminCatalogMobileScreen: React.FC = () => {
{/* Delete Confirmation Sheet */} {/* Delete Confirmation Sheet */}
{deleteSheet.open && deleteSheet.item && ( {deleteSheet.open && deleteSheet.item && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center"> <div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center">
<div className="bg-white rounded-t-2xl w-full max-w-lg p-6 space-y-4 animate-slide-up"> <div className="bg-white dark:bg-scuro rounded-t-2xl w-full max-w-lg p-6 space-y-4 animate-slide-up">
<h2 className="text-xl font-bold text-slate-800">Delete Configuration?</h2> <h2 className="text-xl font-bold text-slate-800 dark:text-avus">Delete Configuration?</h2>
<p className="text-slate-600"> <p className="text-slate-600 dark:text-titanio">
Are you sure you want to delete{' '} Are you sure you want to delete{' '}
<strong> <strong>
{deleteSheet.item.year} {deleteSheet.item.make} {deleteSheet.item.model}{' '} {deleteSheet.item.year} {deleteSheet.item.make} {deleteSheet.item.model}{' '}
@@ -413,7 +441,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
<button <button
onClick={() => setDeleteSheet({ open: false, item: null })} onClick={() => setDeleteSheet({ open: false, item: null })}
disabled={deleting} disabled={deleting}
className="flex-1 bg-slate-200 text-slate-700 py-3 rounded-lg font-medium hover:bg-slate-300 transition disabled:opacity-50" className="flex-1 bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-200 py-3 rounded-lg font-medium hover:bg-slate-300 dark:hover:bg-gray-600 transition disabled:opacity-50"
style={{ minHeight: '44px' }} style={{ minHeight: '44px' }}
> >
Cancel Cancel
@@ -435,17 +463,16 @@ export const AdminCatalogMobileScreen: React.FC = () => {
</div> </div>
)} )}
{/* Import Preview Sheet */} {/* Import Preview/Results Sheet */}
{importSheet && importPreview && ( {importSheet && (importPreview || importResult) && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center"> <div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center">
<div className="bg-white rounded-t-2xl w-full max-w-lg p-6 space-y-4 animate-slide-up max-h-[80vh] overflow-y-auto"> <div className="bg-white dark:bg-scuro rounded-t-2xl w-full max-w-lg p-6 space-y-4 animate-slide-up max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-slate-800">Import Preview</h2> <h2 className="text-xl font-bold text-slate-800">
{importResult ? 'Import Results' : 'Import Preview'}
</h2>
<button <button
onClick={() => { onClick={handleImportSheetClose}
setImportSheet(false);
setImportPreview(null);
}}
disabled={importApplyMutation.isPending} disabled={importApplyMutation.isPending}
className="p-2 text-slate-500 hover:text-slate-700" className="p-2 text-slate-500 hover:text-slate-700"
style={{ minHeight: '44px', minWidth: '44px' }} style={{ minHeight: '44px', minWidth: '44px' }}
@@ -454,74 +481,127 @@ export const AdminCatalogMobileScreen: React.FC = () => {
</button> </button>
</div> </div>
{/* Summary */} {/* Preview Mode */}
<div className="flex gap-4 text-sm"> {importPreview && !importResult && (
<div className="bg-green-100 text-green-800 px-3 py-2 rounded-lg"> <>
<strong>{importPreview.toCreate.length}</strong> to create <div className="flex gap-4 text-sm">
</div> <div className="bg-green-100 text-green-800 px-3 py-2 rounded-lg">
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-lg"> <strong>{importPreview.toCreate.length}</strong> to create
<strong>{importPreview.toUpdate.length}</strong> to update </div>
</div> <div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-lg">
</div> <strong>{importPreview.toUpdate.length}</strong> to update
</div>
</div>
{/* Errors */} {importPreview.errors.length > 0 && (
{importPreview.errors.length > 0 && ( <div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="bg-red-50 border border-red-200 rounded-lg p-3"> <p className="text-red-800 font-semibold mb-2">
<p className="text-red-800 font-semibold mb-2"> {importPreview.errors.length} Error(s) Found:
{importPreview.errors.length} Error(s) Found: </p>
</p> <ul className="text-red-700 text-sm space-y-1">
<ul className="text-red-700 text-sm space-y-1"> {importPreview.errors.slice(0, 5).map((err, idx) => (
{importPreview.errors.slice(0, 5).map((err, idx) => ( <li key={idx}>
<li key={idx}> Row {err.row}: {err.error}
Row {err.row}: {err.error} </li>
</li> ))}
))} {importPreview.errors.length > 5 && (
{importPreview.errors.length > 5 && ( <li>...and {importPreview.errors.length - 5} more errors</li>
<li>...and {importPreview.errors.length - 5} more errors</li> )}
)} </ul>
</ul> </div>
</div> )}
{importPreview.valid ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-green-800">
The import file is valid and ready to be applied.
</p>
</div>
) : (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-amber-800">
Please fix the errors above before importing.
</p>
</div>
)}
</>
)} )}
{/* Status */} {/* Results Mode */}
{importPreview.valid ? ( {importResult && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3"> <>
<p className="text-green-800"> <div className="flex gap-4 text-sm">
The import file is valid and ready to be applied. <div className="bg-green-100 text-green-800 px-3 py-2 rounded-lg">
</p> <strong>{importResult.created}</strong> created
</div> </div>
) : ( <div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-lg">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3"> <strong>{importResult.updated}</strong> updated
<p className="text-amber-800"> </div>
Please fix the errors above before importing. </div>
</p>
</div> {importResult.errors.length > 0 && (
<div className="border border-red-500 rounded-lg overflow-hidden">
<button
onClick={() => setErrorsExpanded(!errorsExpanded)}
className="w-full flex items-center justify-between p-4 bg-red-100 hover:bg-red-200 transition"
style={{ minHeight: '44px' }}
>
<span className="text-red-900 font-semibold">
{importResult.errors.length} Error(s) Occurred
</span>
<span className="text-red-900">
{errorsExpanded ? <ExpandLess /> : <ExpandMore />}
</span>
</button>
{errorsExpanded && (
<div className="max-h-96 overflow-y-auto p-4 bg-white">
<ul className="space-y-2">
{importResult.errors.map((err, idx) => (
<li key={idx} className="text-sm font-mono text-slate-700">
<strong>Row {err.row}:</strong> {err.error}
</li>
))}
</ul>
</div>
)}
</div>
)}
{importResult.errors.length === 0 && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-green-800">
Import completed successfully with no errors.
</p>
</div>
)}
</>
)} )}
{/* Action Buttons */}
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<button <button
onClick={() => { onClick={handleImportSheetClose}
setImportSheet(false);
setImportPreview(null);
}}
disabled={importApplyMutation.isPending} disabled={importApplyMutation.isPending}
className="flex-1 bg-slate-200 text-slate-700 py-3 rounded-lg font-medium hover:bg-slate-300 transition disabled:opacity-50" className="flex-1 bg-slate-200 text-slate-700 py-3 rounded-lg font-medium hover:bg-slate-300 transition disabled:opacity-50"
style={{ minHeight: '44px' }} style={{ minHeight: '44px' }}
> >
Cancel {importResult ? 'Close' : 'Cancel'}
</button>
<button
onClick={handleImportConfirm}
disabled={!importPreview.valid || importApplyMutation.isPending}
className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition disabled:opacity-50"
style={{ minHeight: '44px' }}
>
{importApplyMutation.isPending ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mx-auto" />
) : (
'Apply Import'
)}
</button> </button>
{!importResult && (
<button
onClick={handleImportConfirm}
disabled={!importPreview?.valid || importApplyMutation.isPending}
className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition disabled:opacity-50"
style={{ minHeight: '44px' }}
>
{importApplyMutation.isPending ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mx-auto" />
) : (
'Apply Import'
)}
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -38,6 +38,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
const [editIsActive, setEditIsActive] = useState(true); const [editIsActive, setEditIsActive] = useState(true);
const [previewSubject, setPreviewSubject] = useState(''); const [previewSubject, setPreviewSubject] = useState('');
const [previewBody, setPreviewBody] = useState(''); const [previewBody, setPreviewBody] = useState('');
const [previewHtml, setPreviewHtml] = useState('');
const [showHtmlPreview, setShowHtmlPreview] = useState(false);
// Queries // Queries
const { data: templates, isLoading } = useQuery({ const { data: templates, isLoading } = useQuery({
@@ -66,6 +68,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
onSuccess: (data) => { onSuccess: (data) => {
setPreviewSubject(data.subject); setPreviewSubject(data.subject);
setPreviewBody(data.body); setPreviewBody(data.body);
setPreviewHtml(data.html || '');
}, },
onError: () => { onError: () => {
toast.error('Failed to generate preview'); toast.error('Failed to generate preview');
@@ -117,6 +120,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
setPreviewTemplate(null); setPreviewTemplate(null);
setPreviewSubject(''); setPreviewSubject('');
setPreviewBody(''); setPreviewBody('');
setPreviewHtml('');
setShowHtmlPreview(false);
}, []); }, []);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
@@ -238,7 +243,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
{editingTemplate.variables.map((variable) => ( {editingTemplate.variables.map((variable) => (
<span <span
key={variable} key={variable}
className="inline-block px-2 py-1 bg-white border border-blue-300 rounded text-xs font-mono text-blue-700" className="inline-block px-2 py-1 bg-white dark:bg-gray-800 border border-blue-300 dark:border-blue-600 rounded text-xs font-mono text-blue-700 dark:text-blue-400"
> >
{`{{${variable}}}`} {`{{${variable}}}`}
</span> </span>
@@ -250,7 +255,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<button <button
onClick={handleCloseEdit} onClick={handleCloseEdit}
className="flex-1 px-4 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]" className="flex-1 px-4 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors min-h-[44px]"
> >
Cancel Cancel
</button> </button>
@@ -304,6 +309,23 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
</div> </div>
) : ( ) : (
<> <>
{/* Toggle HTML/Text Preview */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">Show HTML Preview</span>
<button
onClick={() => setShowHtmlPreview(!showHtmlPreview)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors min-h-[44px] min-w-[44px] ${
showHtmlPreview ? 'bg-blue-600' : 'bg-gray-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showHtmlPreview ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <label className="block text-sm font-medium text-slate-700 mb-1">
Subject Subject
@@ -313,14 +335,29 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
</div> </div>
</div> </div>
<div> {showHtmlPreview ? (
<label className="block text-sm font-medium text-slate-700 mb-1"> <div>
Body <label className="block text-sm font-medium text-slate-700 mb-1">
</label> HTML Preview
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap"> </label>
{previewBody} <div className="border border-gray-300 rounded-lg overflow-hidden bg-gray-50">
<iframe
srcDoc={previewHtml}
style={{ width: '100%', height: '400px', border: 'none' }}
title="Email HTML Preview"
/>
</div>
</div> </div>
</div> ) : (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Body (Plain Text)
</label>
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
{previewBody}
</div>
</div>
)}
</> </>
)} )}

View File

@@ -37,8 +37,8 @@ const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-6 max-w-sm w-full shadow-xl"> <div className="bg-white dark:bg-scuro rounded-xl p-6 max-w-sm w-full shadow-xl">
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3> <h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">{title}</h3>
{children} {children}
<div className="flex justify-end gap-2 mt-4"> <div className="flex justify-end gap-2 mt-4">
{actions || ( {actions || (
@@ -337,7 +337,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()} onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
className="w-full px-4 py-3 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]" className="w-full px-4 py-3 rounded-lg border border-slate-200 dark:border-silverstone dark:bg-scuro dark:text-avus focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]"
/> />
{searchInput && ( {searchInput && (
<button <button
@@ -378,7 +378,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
<select <select
value={params.tier || ''} value={params.tier || ''}
onChange={(e) => handleTierFilterChange(e.target.value as SubscriptionTier | '')} onChange={(e) => handleTierFilterChange(e.target.value as SubscriptionTier | '')}
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]" className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-silverstone dark:bg-scuro dark:text-avus min-h-[44px]"
> >
<option value="">All Tiers</option> <option value="">All Tiers</option>
<option value="free">Free</option> <option value="free">Free</option>
@@ -393,7 +393,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
<select <select
value={params.status || 'all'} value={params.status || 'all'}
onChange={(e) => handleStatusFilterChange(e.target.value as 'active' | 'deactivated' | 'all')} onChange={(e) => handleStatusFilterChange(e.target.value as 'active' | 'deactivated' | 'all')}
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]" className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-silverstone dark:bg-scuro dark:text-avus min-h-[44px]"
> >
<option value="all">All</option> <option value="all">All</option>
<option value="active">Active</option> <option value="active">Active</option>

View File

@@ -210,6 +210,12 @@ export interface UpdateEmailTemplateRequest {
isActive?: boolean; isActive?: boolean;
} }
export interface PreviewTemplateResponse {
subject: string;
body: string;
html: string;
}
// ============================================ // ============================================
// User Management types (subscription tiers) // User Management types (subscription tiers)
// ============================================ // ============================================

View File

@@ -114,7 +114,7 @@ export const VerifyEmailPage: React.FC = () => {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 dark:from-paper dark:via-nero dark:to-paper flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 dark:from-paper dark:via-nero dark:to-paper flex items-center justify-center p-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-lg p-8"> <div className="bg-white dark:bg-scuro rounded-lg shadow-lg p-8">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="mx-auto w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mb-4"> <div className="mx-auto w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mb-4">
<svg <svg
@@ -131,8 +131,8 @@ export const VerifyEmailPage: React.FC = () => {
/> />
</svg> </svg>
</div> </div>
<h1 className="text-2xl font-bold text-gray-800 mb-2">Check Your Email</h1> <h1 className="text-2xl font-bold text-gray-800 dark:text-avus mb-2">Check Your Email</h1>
<p className="text-gray-600"> <p className="text-gray-600 dark:text-titanio">
We've sent a verification link to We've sent a verification link to
</p> </p>
{email && ( {email && (
@@ -143,7 +143,7 @@ export const VerifyEmailPage: React.FC = () => {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-slate-50 rounded-lg p-4 text-sm text-gray-700"> <div className="bg-slate-50 dark:bg-gray-800 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300">
<p className="mb-2">Click the link in the email to verify your account.</p> <p className="mb-2">Click the link in the email to verify your account.</p>
<p>Once verified, you can log in to complete your profile setup.</p> <p>Once verified, you can log in to complete your profile setup.</p>
</div> </div>

View File

@@ -56,8 +56,8 @@ export const DocumentsMobileScreen: React.FC = () => {
<div className="space-y-4"> <div className="space-y-4">
<GlassCard> <GlassCard>
<div className="p-4"> <div className="p-4">
<h2 className="text-lg font-semibold text-slate-800 mb-2">Documents</h2> <h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">Documents</h2>
<div className="text-slate-500 py-6 text-center">Loading...</div> <div className="text-slate-500 dark:text-titanio py-6 text-center">Loading...</div>
</div> </div>
</GlassCard> </GlassCard>
</div> </div>
@@ -77,8 +77,8 @@ export const DocumentsMobileScreen: React.FC = () => {
</svg> </svg>
</div> </div>
</div> </div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Login Required</h3> <h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">Login Required</h3>
<p className="text-slate-600 text-sm mb-4">Please log in to view your documents</p> <p className="text-slate-600 dark:text-titanio text-sm mb-4">Please log in to view your documents</p>
<button <button
onClick={() => loginWithRedirect()} onClick={() => loginWithRedirect()}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
@@ -106,8 +106,8 @@ export const DocumentsMobileScreen: React.FC = () => {
</svg> </svg>
</div> </div>
</div> </div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Session Expired</h3> <h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">Session Expired</h3>
<p className="text-slate-600 text-sm mb-4">Your session has expired. Please log in again.</p> <p className="text-slate-600 dark:text-titanio text-sm mb-4">Your session has expired. Please log in again.</p>
<button <button
onClick={() => loginWithRedirect()} onClick={() => loginWithRedirect()}
className="w-full px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors" className="w-full px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors"
@@ -126,13 +126,13 @@ export const DocumentsMobileScreen: React.FC = () => {
<AddDocumentDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} /> <AddDocumentDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
<GlassCard> <GlassCard>
<div className="p-4"> <div className="p-4">
<h2 className="text-lg font-semibold text-slate-800 mb-2">Documents</h2> <h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">Documents</h2>
<div className="flex justify-end mb-2"> <div className="flex justify-end mb-2">
<Button onClick={() => setIsAddOpen(true)} className="min-h-[44px]">Add Document</Button> <Button onClick={() => setIsAddOpen(true)} className="min-h-[44px]">Add Document</Button>
</div> </div>
{isLoading && <div className="text-slate-500 py-6 text-center">Loading...</div>} {isLoading && <div className="text-slate-500 dark:text-titanio py-6 text-center">Loading...</div>}
{hasError && !isAuthError && ( {hasError && !isAuthError && (
<div className="py-6 text-center"> <div className="py-6 text-center">
@@ -162,8 +162,8 @@ export const DocumentsMobileScreen: React.FC = () => {
</svg> </svg>
</div> </div>
</div> </div>
<p className="text-slate-600 text-sm mb-3">No documents yet</p> <p className="text-slate-600 dark:text-titanio text-sm mb-3">No documents yet</p>
<p className="text-slate-500 text-xs">Documents will appear here once you create them</p> <p className="text-slate-500 dark:text-titanio text-xs">Documents will appear here once you create them</p>
</div> </div>
)} )}
@@ -174,8 +174,8 @@ export const DocumentsMobileScreen: React.FC = () => {
return ( return (
<div key={doc.id} className="flex items-center justify-between border rounded-xl p-3"> <div key={doc.id} className="flex items-center justify-between border rounded-xl p-3">
<div> <div>
<div className="font-medium text-slate-800">{doc.title}</div> <div className="font-medium text-slate-800 dark:text-avus">{doc.title}</div>
<div className="text-xs text-slate-500">{doc.documentType} {vehicleLabel}</div> <div className="text-xs text-slate-500 dark:text-titanio">{doc.documentType} {vehicleLabel}</div>
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button> <Button onClick={() => navigate(`/garage/documents/${doc.id}`)}>Open</Button>

View File

@@ -188,10 +188,16 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
InputProps={{ InputProps={{
readOnly: true, readOnly: true,
sx: (theme) => ({ sx: (theme) => ({
backgroundColor: theme.palette.mode === 'dark' ? '#4C4E4D' : 'grey.50', backgroundColor: 'grey.50',
...theme.applyStyles('dark', {
backgroundColor: '#4C4E4D',
}),
'& .MuiOutlinedInput-input': { '& .MuiOutlinedInput-input': {
cursor: 'default', cursor: 'default',
color: theme.palette.mode === 'dark' ? '#F2F3F6' : 'inherit', color: 'inherit',
...theme.applyStyles('dark', {
color: '#F2F3F6',
}),
}, },
}), }),
}} }}

View File

@@ -333,7 +333,10 @@ export const StationPicker: React.FC<StationPickerProps> = ({
sx={(theme) => ({ sx={(theme) => ({
'& .MuiAutocomplete-groupLabel': { '& .MuiAutocomplete-groupLabel': {
fontWeight: 600, fontWeight: 600,
backgroundColor: theme.palette.mode === 'dark' ? '#4C4E4D' : 'grey.100', backgroundColor: 'grey.100',
...theme.applyStyles('dark', {
backgroundColor: '#4C4E4D',
}),
fontSize: '0.75rem', fontSize: '0.75rem',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.5px' letterSpacing: '0.5px'

View File

@@ -36,13 +36,13 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-md w-full"> <div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-semibold text-slate-800 mb-4">Delete Account</h3> <h3 className="text-xl font-semibold text-slate-800 dark:text-avus mb-4">Delete Account</h3>
{/* Warning Alert */} {/* Warning Alert */}
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg"> <div className="mb-4 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p className="font-semibold text-amber-900 mb-2">30-Day Grace Period</p> <p className="font-semibold text-amber-900 dark:text-amber-200 mb-2">30-Day Grace Period</p>
<p className="text-sm text-amber-800"> <p className="text-sm text-amber-800 dark:text-amber-300">
Your account will be scheduled for deletion in 30 days. You can cancel this request at any time during Your account will be scheduled for deletion in 30 days. You can cancel this request at any time during
the grace period by logging back in. the grace period by logging back in.
</p> </p>
@@ -50,7 +50,7 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
{/* Confirmation Input */} {/* Confirmation Input */}
<div className="mb-6"> <div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-1"> <label className="block text-sm font-medium text-slate-700 dark:text-avus mb-1">
Type DELETE to confirm Type DELETE to confirm
</label> </label>
<input <input
@@ -58,14 +58,14 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
value={confirmationText} value={confirmationText}
onChange={(e) => setConfirmationText(e.target.value)} onChange={(e) => setConfirmationText(e.target.value)}
placeholder="DELETE" placeholder="DELETE"
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 ${ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 dark:bg-scuro dark:text-avus ${
confirmationText.length > 0 && confirmationText !== 'DELETE' confirmationText.length > 0 && confirmationText !== 'DELETE'
? 'border-red-300 focus:ring-red-500 focus:border-red-500' ? 'border-red-300 dark:border-red-700 focus:ring-red-500 focus:border-red-500'
: 'border-slate-300 focus:ring-red-500 focus:border-red-500' : 'border-slate-300 dark:border-silverstone focus:ring-red-500 focus:border-red-500'
}`} }`}
style={{ fontSize: '16px', minHeight: '44px' }} style={{ fontSize: '16px', minHeight: '44px' }}
/> />
<p className="text-xs text-slate-500 mt-1">Type the word "DELETE" (all caps) to confirm</p> <p className="text-xs text-slate-500 dark:text-titanio mt-1">Type the word "DELETE" (all caps) to confirm</p>
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
@@ -73,7 +73,7 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
<button <button
onClick={onClose} onClick={onClose}
disabled={requestDeletionMutation.isPending} disabled={requestDeletionMutation.isPending}
className="flex-1 py-2.5 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors disabled:opacity-50" className="flex-1 py-2.5 px-4 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
style={{ minHeight: '44px' }} style={{ minHeight: '44px' }}
> >
Cancel Cancel

View File

@@ -25,9 +25,9 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
}) => ( }) => (
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<div> <div>
<p className="font-medium text-slate-800">{label}</p> <p className="font-medium text-slate-800 dark:text-avus">{label}</p>
{description && ( {description && (
<p className="text-sm text-slate-500">{description}</p> <p className="text-sm text-slate-500 dark:text-titanio">{description}</p>
)} )}
</div> </div>
<button <button
@@ -56,14 +56,14 @@ const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 dark:bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-sm w-full"> <div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3> <h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">{title}</h3>
{children} {children}
<div className="flex justify-end mt-4"> <div className="flex justify-end mt-4">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium" className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium"
> >
Close Close
</button> </button>
@@ -184,8 +184,8 @@ export const MobileSettingsScreen: React.FC = () => {
<div className="space-y-4 pb-20 p-4"> <div className="space-y-4 pb-20 p-4">
{/* Header */} {/* Header */}
<div className="text-center mb-6"> <div className="text-center mb-6">
<h1 className="text-2xl font-bold text-slate-800">Settings</h1> <h1 className="text-2xl font-bold text-slate-800 dark:text-avus">Settings</h1>
<p className="text-slate-500 mt-2">Manage your account and preferences</p> <p className="text-slate-500 dark:text-titanio mt-2">Manage your account and preferences</p>
</div> </div>
{/* Pending Deletion Banner */} {/* Pending Deletion Banner */}
@@ -195,7 +195,7 @@ export const MobileSettingsScreen: React.FC = () => {
<GlassCard padding="md"> <GlassCard padding="md">
<div> <div>
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-slate-800">Profile</h2> <h2 className="text-lg font-semibold text-slate-800 dark:text-avus">Profile</h2>
{!isEditingProfile && !profileLoading && ( {!isEditingProfile && !profileLoading && (
<button <button
onClick={handleEditProfile} onClick={handleEditProfile}
@@ -214,21 +214,21 @@ export const MobileSettingsScreen: React.FC = () => {
) : isEditingProfile ? ( ) : isEditingProfile ? (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <label className="block text-sm font-medium text-slate-700 dark:text-avus mb-1">
Email Email
</label> </label>
<input <input
type="email" type="email"
value={profile?.email || ''} value={profile?.email || ''}
disabled disabled
className="w-full px-3 py-2 border border-slate-300 rounded-lg bg-slate-100 text-slate-500" className="w-full px-3 py-2 border border-slate-300 dark:border-silverstone rounded-lg bg-slate-100 dark:bg-gray-800 text-slate-500 dark:text-gray-400"
style={{ fontSize: '16px', minHeight: '44px' }} style={{ fontSize: '16px', minHeight: '44px' }}
/> />
<p className="text-xs text-slate-500 mt-1">Email is managed by your account provider</p> <p className="text-xs text-slate-500 dark:text-titanio mt-1">Email is managed by your account provider</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <label className="block text-sm font-medium text-slate-700 dark:text-avus mb-1">
Display Name Display Name
</label> </label>
<input <input
@@ -236,13 +236,13 @@ export const MobileSettingsScreen: React.FC = () => {
value={editedDisplayName} value={editedDisplayName}
onChange={(e) => setEditedDisplayName(e.target.value)} onChange={(e) => setEditedDisplayName(e.target.value)}
placeholder="Enter your display name" placeholder="Enter your display name"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:border-silverstone dark:text-avus" className="w-full px-3 py-2 border border-slate-300 dark:border-silverstone rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:text-avus"
style={{ fontSize: '16px', minHeight: '44px' }} style={{ fontSize: '16px', minHeight: '44px' }}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <label className="block text-sm font-medium text-slate-700 dark:text-avus mb-1">
Notification Email Notification Email
</label> </label>
<input <input
@@ -250,17 +250,17 @@ export const MobileSettingsScreen: React.FC = () => {
value={editedNotificationEmail} value={editedNotificationEmail}
onChange={(e) => setEditedNotificationEmail(e.target.value)} onChange={(e) => setEditedNotificationEmail(e.target.value)}
placeholder="Leave blank to use your primary email" placeholder="Leave blank to use your primary email"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:border-silverstone dark:text-avus" className="w-full px-3 py-2 border border-slate-300 dark:border-silverstone rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-nero dark:text-avus"
style={{ fontSize: '16px', minHeight: '44px' }} style={{ fontSize: '16px', minHeight: '44px' }}
/> />
<p className="text-xs text-slate-500 mt-1">Optional: Use a different email for notifications</p> <p className="text-xs text-slate-500 dark:text-titanio mt-1">Optional: Use a different email for notifications</p>
</div> </div>
<div className="flex space-x-3 pt-2"> <div className="flex space-x-3 pt-2">
<button <button
onClick={handleCancelEdit} onClick={handleCancelEdit}
disabled={updateProfileMutation.isPending} disabled={updateProfileMutation.isPending}
className="flex-1 py-2.5 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors disabled:opacity-50" className="flex-1 py-2.5 px-4 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
style={{ minHeight: '44px' }} style={{ minHeight: '44px' }}
> >
Cancel Cancel
@@ -294,21 +294,21 @@ export const MobileSettingsScreen: React.FC = () => {
</div> </div>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium text-slate-800 truncate"> <p className="font-medium text-slate-800 dark:text-avus truncate">
{profile?.displayName || user?.name || 'User'} {profile?.displayName || user?.name || 'User'}
</p> </p>
<p className="text-sm text-slate-500 truncate">{profile?.email || user?.email}</p> <p className="text-sm text-slate-500 dark:text-titanio truncate">{profile?.email || user?.email}</p>
</div> </div>
</div> </div>
<div className="space-y-2 pt-3 border-t border-slate-200"> <div className="space-y-2 pt-3 border-t border-slate-200 dark:border-silverstone">
<div> <div>
<p className="text-xs font-medium text-slate-500 uppercase">Display Name</p> <p className="text-xs font-medium text-slate-500 dark:text-canna uppercase">Display Name</p>
<p className="text-sm text-slate-800">{profile?.displayName || 'Not set'}</p> <p className="text-sm text-slate-800 dark:text-avus">{profile?.displayName || 'Not set'}</p>
</div> </div>
<div> <div>
<p className="text-xs font-medium text-slate-500 uppercase">Notification Email</p> <p className="text-xs font-medium text-slate-500 dark:text-canna uppercase">Notification Email</p>
<p className="text-sm text-slate-800">{profile?.notificationEmail || 'Using primary email'}</p> <p className="text-sm text-slate-800 dark:text-avus">{profile?.notificationEmail || 'Using primary email'}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -319,7 +319,7 @@ export const MobileSettingsScreen: React.FC = () => {
{/* Notifications Section */} {/* Notifications Section */}
<GlassCard padding="md"> <GlassCard padding="md">
<div> <div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Notifications</h2> <h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Notifications</h2>
<div className="space-y-3"> <div className="space-y-3">
<ToggleSwitch <ToggleSwitch
enabled={settings.notifications.email} enabled={settings.notifications.email}
@@ -355,7 +355,7 @@ export const MobileSettingsScreen: React.FC = () => {
{/* Appearance & Units Section */} {/* Appearance & Units Section */}
<GlassCard padding="md"> <GlassCard padding="md">
<div> <div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Appearance & Units</h2> <h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Appearance & Units</h2>
<div className="space-y-4"> <div className="space-y-4">
<ToggleSwitch <ToggleSwitch
enabled={settings.darkMode} enabled={settings.darkMode}
@@ -366,8 +366,8 @@ export const MobileSettingsScreen: React.FC = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="font-medium text-slate-800">Unit System</p> <p className="font-medium text-slate-800 dark:text-avus">Unit System</p>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500 dark:text-titanio">
Currently using {settings.unitSystem === 'imperial' ? 'Miles, Gallons, MPG, USD' : 'Km, Liters, L/100km, EUR'} Currently using {settings.unitSystem === 'imperial' ? 'Miles, Gallons, MPG, USD' : 'Km, Liters, L/100km, EUR'}
</p> </p>
</div> </div>
@@ -385,7 +385,7 @@ export const MobileSettingsScreen: React.FC = () => {
{/* Data Management Section */} {/* Data Management Section */}
<GlassCard padding="md"> <GlassCard padding="md">
<div> <div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Data Management</h2> <h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Data Management</h2>
<div className="space-y-3"> <div className="space-y-3">
<button <button
onClick={() => setShowDataExport(true)} onClick={() => setShowDataExport(true)}
@@ -393,7 +393,7 @@ export const MobileSettingsScreen: React.FC = () => {
> >
Export My Data Export My Data
</button> </button>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500 dark:text-titanio">
Download a copy of all your vehicle and fuel data Download a copy of all your vehicle and fuel data
</p> </p>
</div> </div>
@@ -421,7 +421,7 @@ export const MobileSettingsScreen: React.FC = () => {
{!adminLoading && isAdmin && ( {!adminLoading && isAdmin && (
<GlassCard padding="md"> <GlassCard padding="md">
<div> <div>
<h2 className="text-lg font-semibold text-primary-500 mb-4">Admin Console</h2> <h2 className="text-lg font-semibold text-primary-500 dark:text-primary-400 mb-4">Admin Console</h2>
<div className="space-y-3"> <div className="space-y-3">
<button <button
onClick={() => navigateToScreen('AdminUsers')} onClick={() => navigateToScreen('AdminUsers')}
@@ -463,11 +463,11 @@ export const MobileSettingsScreen: React.FC = () => {
{/* Account Actions Section */} {/* Account Actions Section */}
<GlassCard padding="md"> <GlassCard padding="md">
<div> <div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account Actions</h2> <h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Account Actions</h2>
<div className="space-y-3"> <div className="space-y-3">
<button <button
onClick={handleLogout} onClick={handleLogout}
className="w-full py-3 px-4 bg-gray-100 text-gray-700 rounded-lg text-left font-medium hover:bg-gray-200 transition-colors" className="w-full py-3 px-4 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg text-left font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
> >
Sign Out Sign Out
</button> </button>
@@ -487,7 +487,7 @@ export const MobileSettingsScreen: React.FC = () => {
onClose={() => setShowDataExport(false)} onClose={() => setShowDataExport(false)}
title="Export Data" title="Export Data"
> >
<p className="text-slate-600 mb-4"> <p className="text-slate-600 dark:text-titanio mb-4">
This will create a downloadable file containing all your vehicle data, fuel logs, and preferences. This will create a downloadable file containing all your vehicle data, fuel logs, and preferences.
</p> </p>
<div className="flex space-x-3"> <div className="flex space-x-3">

View File

@@ -14,3 +14,24 @@ body {
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
/* Tell browser we support both color schemes for native UI elements (scrollbars, form controls) */
/* This enables iOS Safari to properly handle dark mode */
:root {
color-scheme: light dark;
-webkit-color-scheme: light dark; /* iOS Safari fallback */
}
/* Base styles on html element */
html {
background-color: #ffffff;
color: #1e293b;
}
/* Dark mode overrides when .dark class is present */
html.dark {
background-color: #231F1C;
color: #F2F3F6;
color-scheme: dark;
-webkit-color-scheme: dark;
}

View File

@@ -28,6 +28,7 @@ import {
Tooltip, Tooltip,
Typography, Typography,
Alert, Alert,
Collapse,
} from '@mui/material'; } from '@mui/material';
import { import {
Search, Search,
@@ -35,6 +36,8 @@ import {
FileDownload, FileDownload,
FileUpload, FileUpload,
Clear, Clear,
ExpandMore,
ExpandLess,
} from '@mui/icons-material'; } from '@mui/icons-material';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useAdminAccess } from '../../core/auth/useAdminAccess'; import { useAdminAccess } from '../../core/auth/useAdminAccess';
@@ -52,6 +55,7 @@ import {
import { import {
CatalogSearchResult, CatalogSearchResult,
ImportPreviewResult, ImportPreviewResult,
ImportApplyResult,
} from '../../features/admin/types/admin.types'; } from '../../features/admin/types/admin.types';
const PAGE_SIZE_OPTIONS = [25, 50, 100]; const PAGE_SIZE_OPTIONS = [25, 50, 100];
@@ -76,6 +80,8 @@ export const AdminCatalogPage: React.FC = () => {
// Import state // Import state
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importPreview, setImportPreview] = useState<ImportPreviewResult | null>(null); const [importPreview, setImportPreview] = useState<ImportPreviewResult | null>(null);
const [importResult, setImportResult] = useState<ImportApplyResult | null>(null);
const [errorsExpanded, setErrorsExpanded] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Hooks // Hooks
@@ -217,15 +223,38 @@ export const AdminCatalogPage: React.FC = () => {
if (!importPreview?.previewId) return; if (!importPreview?.previewId) return;
try { try {
await importApplyMutation.mutateAsync(importPreview.previewId); const result = await importApplyMutation.mutateAsync(importPreview.previewId);
setImportDialogOpen(false); setImportResult(result);
setImportPreview(null);
if (result.errors.length > 0) {
toast.error(
`Import completed with ${result.errors.length} error(s): ${result.created} created, ${result.updated} updated`
);
// Keep dialog open for error review
} else {
toast.success(
`Import completed successfully: ${result.created} created, ${result.updated} updated`
);
// Auto-close on complete success
setImportDialogOpen(false);
setImportPreview(null);
setImportResult(null);
}
refetch(); refetch();
} catch (error) { } catch (error) {
// Error is handled by mutation // Error is handled by mutation's onError
} }
}, [importPreview, importApplyMutation, refetch]); }, [importPreview, importApplyMutation, refetch]);
const handleImportDialogClose = useCallback(() => {
if (importApplyMutation.isPending) return;
setImportDialogOpen(false);
setImportPreview(null);
setImportResult(null);
setErrorsExpanded(false);
}, [importApplyMutation.isPending]);
// Export handler // Export handler
const handleExport = useCallback(() => { const handleExport = useCallback(() => {
exportMutation.mutate(); exportMutation.mutate();
@@ -506,18 +535,20 @@ export const AdminCatalogPage: React.FC = () => {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Import Preview Dialog */} {/* Import Preview/Results Dialog */}
<Dialog <Dialog
open={importDialogOpen} open={importDialogOpen}
onClose={() => !importApplyMutation.isPending && setImportDialogOpen(false)} onClose={handleImportDialogClose}
maxWidth="md" maxWidth="md"
fullWidth fullWidth
> >
<DialogTitle>Import Preview</DialogTitle> <DialogTitle>
{importResult ? 'Import Results' : 'Import Preview'}
</DialogTitle>
<DialogContent> <DialogContent>
{importPreview && ( {/* Preview Mode */}
{importPreview && !importResult && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
{/* Summary */}
<Box sx={{ display: 'flex', gap: 3 }}> <Box sx={{ display: 'flex', gap: 3 }}>
<Typography> <Typography>
<strong>To Create:</strong> {importPreview.toCreate.length} <strong>To Create:</strong> {importPreview.toCreate.length}
@@ -527,7 +558,6 @@ export const AdminCatalogPage: React.FC = () => {
</Typography> </Typography>
</Box> </Box>
{/* Errors */}
{importPreview.errors.length > 0 && ( {importPreview.errors.length > 0 && (
<Alert severity="error"> <Alert severity="error">
<Typography variant="subtitle2" gutterBottom> <Typography variant="subtitle2" gutterBottom>
@@ -546,7 +576,6 @@ export const AdminCatalogPage: React.FC = () => {
</Alert> </Alert>
)} )}
{/* Valid status */}
{importPreview.valid ? ( {importPreview.valid ? (
<Alert severity="success"> <Alert severity="success">
The import file is valid and ready to be applied. The import file is valid and ready to be applied.
@@ -558,23 +587,86 @@ export const AdminCatalogPage: React.FC = () => {
)} )}
</Box> </Box>
)} )}
{/* Results Mode */}
{importResult && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<Box sx={{ display: 'flex', gap: 3 }}>
<Typography>
<strong>Created:</strong> {importResult.created}
</Typography>
<Typography>
<strong>Updated:</strong> {importResult.updated}
</Typography>
</Box>
{importResult.errors.length > 0 && (
<Box sx={{ border: 1, borderColor: 'error.main', borderRadius: 1 }}>
<Box
onClick={() => setErrorsExpanded(!errorsExpanded)}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
bgcolor: 'error.light',
cursor: 'pointer',
'&:hover': { bgcolor: 'error.main', color: 'white' },
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{importResult.errors.length} Error(s) Occurred
</Typography>
<IconButton size="small" sx={{ color: 'inherit' }}>
{errorsExpanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</Box>
<Collapse in={errorsExpanded}>
<Box sx={{ maxHeight: 400, overflow: 'auto', p: 2, bgcolor: 'background.paper' }}>
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{importResult.errors.map((err, idx) => (
<Typography
component="li"
key={idx}
variant="body2"
sx={{ mb: 1, fontFamily: 'monospace', fontSize: '0.875rem' }}
>
<strong>Row {err.row}:</strong> {err.error}
</Typography>
))}
</Box>
</Box>
</Collapse>
</Box>
)}
{importResult.errors.length === 0 && (
<Alert severity="success">
Import completed successfully with no errors.
</Alert>
)}
</Box>
)}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button <Button
onClick={() => setImportDialogOpen(false)} onClick={handleImportDialogClose}
disabled={importApplyMutation.isPending} disabled={importApplyMutation.isPending}
sx={{ textTransform: 'none' }} sx={{ textTransform: 'none' }}
> >
Cancel {importResult ? 'Close' : 'Cancel'}
</Button>
<Button
onClick={handleImportConfirm}
disabled={!importPreview?.valid || importApplyMutation.isPending}
variant="contained"
sx={{ textTransform: 'none' }}
>
{importApplyMutation.isPending ? <CircularProgress size={20} /> : 'Apply Import'}
</Button> </Button>
{!importResult && (
<Button
onClick={handleImportConfirm}
disabled={!importPreview?.valid || importApplyMutation.isPending}
variant="contained"
sx={{ textTransform: 'none' }}
>
{importApplyMutation.isPending ? <CircularProgress size={20} /> : 'Apply Import'}
</Button>
)}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Box> </Box>

View File

@@ -59,6 +59,8 @@ export const AdminEmailTemplatesPage: React.FC = () => {
const [editIsActive, setEditIsActive] = useState(true); const [editIsActive, setEditIsActive] = useState(true);
const [previewSubject, setPreviewSubject] = useState(''); const [previewSubject, setPreviewSubject] = useState('');
const [previewBody, setPreviewBody] = useState(''); const [previewBody, setPreviewBody] = useState('');
const [previewHtml, setPreviewHtml] = useState('');
const [showHtmlPreview, setShowHtmlPreview] = useState(false);
// Queries // Queries
const { data: templates, isLoading } = useQuery({ const { data: templates, isLoading } = useQuery({
@@ -87,6 +89,7 @@ export const AdminEmailTemplatesPage: React.FC = () => {
onSuccess: (data) => { onSuccess: (data) => {
setPreviewSubject(data.subject); setPreviewSubject(data.subject);
setPreviewBody(data.body); setPreviewBody(data.body);
setPreviewHtml(data.html || '');
setPreviewDialogOpen(true); setPreviewDialogOpen(true);
}, },
onError: () => { onError: () => {
@@ -141,6 +144,8 @@ export const AdminEmailTemplatesPage: React.FC = () => {
setPreviewDialogOpen(false); setPreviewDialogOpen(false);
setPreviewSubject(''); setPreviewSubject('');
setPreviewBody(''); setPreviewBody('');
setPreviewHtml('');
setShowHtmlPreview(false);
}, []); }, []);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
@@ -362,6 +367,16 @@ export const AdminEmailTemplatesPage: React.FC = () => {
This preview uses sample data to show how the template will appear. This preview uses sample data to show how the template will appear.
</Alert> </Alert>
<FormControlLabel
control={
<Switch
checked={showHtmlPreview}
onChange={(e) => setShowHtmlPreview(e.target.checked)}
/>
}
label="Show HTML Preview"
/>
<TextField <TextField
label="Subject" label="Subject"
fullWidth fullWidth
@@ -371,19 +386,41 @@ export const AdminEmailTemplatesPage: React.FC = () => {
}} }}
/> />
<TextField {showHtmlPreview ? (
label="Body" <Box>
fullWidth <Typography variant="subtitle2" gutterBottom>
multiline HTML Preview
rows={12} </Typography>
value={previewBody} <Box
InputProps={{ sx={{
readOnly: true, border: '1px solid #e2e8f0',
}} borderRadius: 1,
inputProps={{ overflow: 'hidden',
style: { fontFamily: 'monospace' }, backgroundColor: '#f8f9fa',
}} }}
/> >
<iframe
srcDoc={previewHtml}
style={{ width: '100%', height: '500px', border: 'none' }}
title="Email HTML Preview"
/>
</Box>
</Box>
) : (
<TextField
label="Body (Plain Text)"
fullWidth
multiline
rows={12}
value={previewBody}
InputProps={{
readOnly: true,
}}
inputProps={{
style: { fontFamily: 'monospace' },
}}
/>
)}
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>

View File

@@ -28,7 +28,7 @@ export const GlassCard: React.FC<GlassCardProps> = ({
return ( return (
<div <div
className={clsx( className={clsx(
'rounded-3xl border border-slate-200/70 bg-white/80 shadow-sm backdrop-blur', 'rounded-3xl border border-slate-200/70 dark:border-slate-700/70 bg-white/80 dark:bg-nero/80 shadow-sm backdrop-blur',
paddings[padding], paddings[padding],
onClick && 'cursor-pointer hover:shadow-xl hover:-translate-y-0.5 transition', onClick && 'cursor-pointer hover:shadow-xl hover:-translate-y-0.5 transition',
className className

View File

@@ -15,7 +15,7 @@ export const MobileContainer: React.FC<MobileContainerProps> = ({
}) => { }) => {
return ( return (
<div className="w-full min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 dark:from-paper dark:via-nero dark:to-paper flex items-start justify-center p-4 md:py-6"> <div className="w-full min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 dark:from-paper dark:via-nero dark:to-paper flex items-start justify-center p-4 md:py-6">
<div className={`w-full max-w-[380px] min-h-screen md:min-h-[600px] md:rounded-[32px] shadow-2xl flex flex-col border-0 md:border border-slate-200/70 bg-white/90 md:bg-white/70 backdrop-blur-xl ${className}`}> <div className={`w-full max-w-[380px] min-h-screen md:min-h-[600px] md:rounded-[32px] shadow-2xl flex flex-col border-0 md:border border-slate-200/70 dark:border-slate-700/70 bg-white/90 dark:bg-nero/90 md:bg-white/70 md:dark:bg-nero/70 backdrop-blur-xl ${className}`}>
{children} {children}
</div> </div>
</div> </div>

View File

@@ -29,7 +29,7 @@ export const MobilePill: React.FC<MobilePillProps> = ({
"group h-11 rounded-2xl text-sm font-medium border transition flex items-center justify-center gap-2 backdrop-blur", "group h-11 rounded-2xl text-sm font-medium border transition flex items-center justify-center gap-2 backdrop-blur",
active active
? "text-white border-transparent shadow-lg bg-gradient-moto" ? "text-white border-transparent shadow-lg bg-gradient-moto"
: "bg-white/80 text-slate-800 border-slate-200 hover:bg-slate-50", : "bg-white/80 dark:bg-nero/80 text-slate-800 dark:text-avus border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-inactive",
className className
)} )}
> >

152
scripts/ci/auto-rollback.sh Executable file
View File

@@ -0,0 +1,152 @@
#!/bin/bash
# Auto-rollback script for blue-green deployment
# Reverts traffic to the previous healthy stack
#
# Usage: ./auto-rollback.sh [reason]
# reason: Optional description of why rollback is happening
#
# Exit codes:
# 0 - Rollback successful
# 1 - Rollback failed
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
REASON="${1:-Automatic rollback triggered}"
STATE_FILE="$PROJECT_ROOT/config/deployment/state.json"
SWITCH_SCRIPT="$SCRIPT_DIR/switch-traffic.sh"
HEALTH_SCRIPT="$SCRIPT_DIR/health-check.sh"
NOTIFY_SCRIPT="$SCRIPT_DIR/notify.sh"
echo "========================================"
echo "AUTO-ROLLBACK INITIATED"
echo "Reason: $REASON"
echo "Time: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "========================================"
# Determine current and rollback stacks
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
CURRENT_STACK=$(jq -r '.active_stack // "unknown"' "$STATE_FILE")
ROLLBACK_STACK=$(jq -r '.inactive_stack // "unknown"' "$STATE_FILE")
else
echo "ERROR: Cannot determine current stack state"
echo "State file: $STATE_FILE"
exit 1
fi
if [[ "$CURRENT_STACK" == "unknown" ]] || [[ "$ROLLBACK_STACK" == "unknown" ]]; then
echo "ERROR: Invalid stack state"
echo " Current: $CURRENT_STACK"
echo " Rollback target: $ROLLBACK_STACK"
exit 1
fi
echo ""
echo "Stack Status:"
echo " Currently active: $CURRENT_STACK"
echo " Rollback target: $ROLLBACK_STACK"
echo ""
# Verify rollback stack is healthy before switching
echo "Step 1/3: Verifying rollback stack health..."
echo "----------------------------------------"
if [[ -x "$HEALTH_SCRIPT" ]]; then
if ! "$HEALTH_SCRIPT" "$ROLLBACK_STACK" 30; then
echo ""
echo "CRITICAL: Rollback stack ($ROLLBACK_STACK) is NOT healthy!"
echo "Manual intervention required."
echo ""
echo "Troubleshooting steps:"
echo " 1. Check container logs: docker logs mvp-backend-$ROLLBACK_STACK"
echo " 2. Check container status: docker ps -a"
echo " 3. Consider restarting rollback stack"
echo ""
# Send critical notification
if [[ -x "$NOTIFY_SCRIPT" ]]; then
"$NOTIFY_SCRIPT" "rollback_failed" \
"Rollback to $ROLLBACK_STACK failed - stack unhealthy. Manual intervention required. Reason: $REASON" \
|| true
fi
exit 1
fi
echo " OK: Rollback stack is healthy"
else
echo " WARNING: Health check script not found, proceeding anyway"
fi
# Switch traffic to rollback stack
echo ""
echo "Step 2/3: Switching traffic to $ROLLBACK_STACK..."
echo "----------------------------------------"
if [[ -x "$SWITCH_SCRIPT" ]]; then
if ! "$SWITCH_SCRIPT" "$ROLLBACK_STACK" instant; then
echo "ERROR: Traffic switch failed"
if [[ -x "$NOTIFY_SCRIPT" ]]; then
"$NOTIFY_SCRIPT" "rollback_failed" \
"Rollback traffic switch failed. Manual intervention required. Reason: $REASON" \
|| true
fi
exit 1
fi
else
echo "ERROR: Traffic switch script not found: $SWITCH_SCRIPT"
exit 1
fi
# Update state file with rollback info
echo ""
echo "Step 3/3: Updating deployment state..."
echo "----------------------------------------"
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
jq --arg stack "$ROLLBACK_STACK" \
--arg reason "$REASON" \
--arg ts "$TIMESTAMP" \
--arg failed "$CURRENT_STACK" \
'.active_stack = $stack |
.inactive_stack = $failed |
.last_rollback = $ts |
.last_rollback_reason = $reason |
.rollback_available = false' \
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
echo " State updated"
fi
# Send notification
if [[ -x "$NOTIFY_SCRIPT" ]]; then
echo ""
echo "Sending rollback notification..."
"$NOTIFY_SCRIPT" "rollback" \
"Rollback executed. Traffic switched from $CURRENT_STACK to $ROLLBACK_STACK. Reason: $REASON" \
|| echo " WARNING: Notification failed"
fi
echo ""
echo "========================================"
echo "ROLLBACK COMPLETE"
echo "========================================"
echo ""
echo "Summary:"
echo " Previous stack: $CURRENT_STACK (now inactive)"
echo " Current stack: $ROLLBACK_STACK (now active)"
echo " Reason: $REASON"
echo ""
echo "Next steps:"
echo " 1. Investigate why $CURRENT_STACK failed"
echo " 2. Check logs: docker logs mvp-backend-$CURRENT_STACK"
echo " 3. Fix issues before next deployment"
echo ""
exit 0

185
scripts/ci/health-check.sh Executable file
View File

@@ -0,0 +1,185 @@
#!/bin/bash
# Health check script for blue-green deployment
# Verifies container health and HTTP endpoints
#
# Usage: ./health-check.sh <stack> [timeout_seconds]
# stack: blue or green
# timeout_seconds: max wait time (default: 60)
#
# Exit codes:
# 0 - All health checks passed
# 1 - Health check failed
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
STACK="${1:-}"
TIMEOUT="${2:-60}"
if [[ -z "$STACK" ]] || [[ ! "$STACK" =~ ^(blue|green)$ ]]; then
echo "Usage: $0 <blue|green> [timeout_seconds]"
exit 1
fi
FRONTEND_CONTAINER="mvp-frontend-$STACK"
BACKEND_CONTAINER="mvp-backend-$STACK"
echo "========================================"
echo "Health Check - $STACK Stack"
echo "Timeout: ${TIMEOUT}s"
echo "========================================"
# Function to check Docker container health
check_container_health() {
local container="$1"
local status
status=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "not found")
case "$status" in
"healthy")
return 0
;;
"starting")
return 2 # Still starting
;;
"unhealthy"|"not found"|"")
return 1
;;
*)
return 1
;;
esac
}
# Function to check HTTP endpoint
check_http_endpoint() {
local container="$1"
local port="$2"
local path="$3"
if docker exec "$container" curl -sf "http://localhost:${port}${path}" > /dev/null 2>&1; then
return 0
else
return 1
fi
}
# Function to check database connectivity via backend
check_database_connectivity() {
local container="$1"
# The /health endpoint should verify database connectivity
if docker exec "$container" curl -sf "http://localhost:3001/health" 2>/dev/null | grep -q '"database"'; then
return 0
else
return 1
fi
}
# Wait for containers to be healthy
wait_for_health() {
local container="$1"
local elapsed=0
while [[ $elapsed -lt $TIMEOUT ]]; do
check_container_health "$container"
local status=$?
if [[ $status -eq 0 ]]; then
return 0
elif [[ $status -eq 1 ]]; then
echo " ERROR: Container $container is unhealthy"
docker logs "$container" --tail 20 2>/dev/null || true
return 1
fi
# Still starting, wait
sleep 2
elapsed=$((elapsed + 2))
echo " Waiting for $container... (${elapsed}s/${TIMEOUT}s)"
done
echo " ERROR: Timeout waiting for $container"
return 1
}
# Main health check sequence
echo ""
echo "Step 1/4: Checking container status..."
echo "----------------------------------------"
for container in "$FRONTEND_CONTAINER" "$BACKEND_CONTAINER"; do
running=$(docker inspect --format='{{.State.Running}}' "$container" 2>/dev/null || echo "false")
if [[ "$running" != "true" ]]; then
echo " ERROR: Container $container is not running"
docker ps -a --filter "name=$container" --format "table {{.Names}}\t{{.Status}}"
exit 1
fi
echo " OK: $container is running"
done
echo ""
echo "Step 2/4: Waiting for Docker health checks..."
echo "----------------------------------------"
for container in "$FRONTEND_CONTAINER" "$BACKEND_CONTAINER"; do
echo " Checking $container..."
if ! wait_for_health "$container"; then
echo " FAILED: $container health check"
exit 1
fi
echo " OK: $container is healthy"
done
echo ""
echo "Step 3/4: Verifying HTTP endpoints..."
echo "----------------------------------------"
# Check frontend
echo " Checking frontend HTTP..."
if ! check_http_endpoint "$FRONTEND_CONTAINER" 3000 "/"; then
echo " FAILED: Frontend HTTP check"
exit 1
fi
echo " OK: Frontend responds on port 3000"
# Check backend health endpoint
echo " Checking backend HTTP..."
if ! check_http_endpoint "$BACKEND_CONTAINER" 3001 "/health"; then
echo " FAILED: Backend HTTP check"
exit 1
fi
echo " OK: Backend responds on port 3001"
echo ""
echo "Step 4/4: Verifying database connectivity..."
echo "----------------------------------------"
if ! check_database_connectivity "$BACKEND_CONTAINER"; then
echo " WARNING: Could not verify database connectivity"
echo " (Backend may not expose database status in /health)"
else
echo " OK: Database connectivity verified"
fi
echo ""
echo "========================================"
echo "Health Check PASSED - $STACK Stack"
echo "========================================"
# Update state file
STATE_FILE="$PROJECT_ROOT/config/deployment/state.json"
if [[ -f "$STATE_FILE" ]]; then
# Update stack health status using jq if available
if command -v jq &> /dev/null; then
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
jq --arg stack "$STACK" --arg ts "$TIMESTAMP" \
'.[$stack].healthy = true | .[$stack].last_health_check = $ts' \
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
fi
fi
exit 0

220
scripts/ci/maintenance-migrate.sh Executable file
View File

@@ -0,0 +1,220 @@
#!/bin/bash
# Maintenance mode migration script
# Enables maintenance mode, runs migrations, then restores service
#
# Usage: ./maintenance-migrate.sh [backup]
# backup: If set, creates a database backup before migration
#
# This script is for BREAKING migrations that require downtime.
# Non-breaking migrations run automatically on container start.
#
# Exit codes:
# 0 - Migration successful
# 1 - Migration failed (maintenance mode will remain active)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
CREATE_BACKUP="${1:-}"
COMPOSE_FILE="$PROJECT_ROOT/docker-compose.yml"
COMPOSE_BLUE_GREEN="$PROJECT_ROOT/docker-compose.blue-green.yml"
STATE_FILE="$PROJECT_ROOT/config/deployment/state.json"
NOTIFY_SCRIPT="$SCRIPT_DIR/notify.sh"
TRAEFIK_CONFIG="$PROJECT_ROOT/config/traefik/dynamic/blue-green.yml"
BACKUP_DIR="$PROJECT_ROOT/data/backups"
echo "========================================"
echo "MAINTENANCE MODE MIGRATION"
echo "Time: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "========================================"
# Determine active stack
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
ACTIVE_STACK=$(jq -r '.active_stack // "blue"' "$STATE_FILE")
else
ACTIVE_STACK="blue"
fi
BACKEND_CONTAINER="mvp-backend-$ACTIVE_STACK"
echo ""
echo "Configuration:"
echo " Active stack: $ACTIVE_STACK"
echo " Backend container: $BACKEND_CONTAINER"
echo " Create backup: ${CREATE_BACKUP:-no}"
echo ""
# Step 1: Send maintenance notification
echo "Step 1/6: Sending maintenance notification..."
echo "----------------------------------------"
if [[ -x "$NOTIFY_SCRIPT" ]]; then
"$NOTIFY_SCRIPT" "maintenance_start" "Starting maintenance window for database migration" || true
fi
echo " OK"
# Step 2: Enable maintenance mode
echo ""
echo "Step 2/6: Enabling maintenance mode..."
echo "----------------------------------------"
# Update state file
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
jq '.maintenance_mode = true' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
fi
# Stop traffic to both stacks (weight 0)
echo " Stopping traffic to application stacks..."
# Set both stacks to weight 0 - Traefik will return 503
sed -i.bak -E "s/(name: mvp-frontend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 0/" "$TRAEFIK_CONFIG"
sed -i.bak -E "s/(name: mvp-frontend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 0/" "$TRAEFIK_CONFIG"
sed -i.bak -E "s/(name: mvp-backend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 0/" "$TRAEFIK_CONFIG"
sed -i.bak -E "s/(name: mvp-backend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 0/" "$TRAEFIK_CONFIG"
rm -f "${TRAEFIK_CONFIG}.bak"
# Wait for Traefik to pick up changes
sleep 3
echo " OK: Maintenance mode active"
# Step 3: Create database backup (if requested)
if [[ -n "$CREATE_BACKUP" ]]; then
echo ""
echo "Step 3/6: Creating database backup..."
echo "----------------------------------------"
mkdir -p "$BACKUP_DIR"
BACKUP_FILE="$BACKUP_DIR/pre-migration-$(date +%Y%m%d-%H%M%S).sql"
if docker exec mvp-postgres pg_dump -U postgres motovaultpro > "$BACKUP_FILE"; then
echo " OK: Backup created: $BACKUP_FILE"
ls -lh "$BACKUP_FILE"
else
echo " ERROR: Backup failed"
echo ""
echo "Aborting migration. Restoring traffic..."
# Restore traffic
if [[ "$ACTIVE_STACK" == "blue" ]]; then
sed -i -E "s/(name: mvp-frontend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
sed -i -E "s/(name: mvp-backend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
else
sed -i -E "s/(name: mvp-frontend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
sed -i -E "s/(name: mvp-backend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
fi
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
jq '.maintenance_mode = false' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
fi
exit 1
fi
else
echo ""
echo "Step 3/6: Skipping backup (not requested)..."
echo "----------------------------------------"
echo " OK"
fi
# Step 4: Run migrations
echo ""
echo "Step 4/6: Running database migrations..."
echo "----------------------------------------"
# Flush Redis cache before migration
echo " Flushing Redis cache..."
docker exec mvp-redis redis-cli FLUSHALL > /dev/null 2>&1 || true
# Run migrations
echo " Running migrations..."
if docker exec "$BACKEND_CONTAINER" npm run migrate; then
echo " OK: Migrations completed"
else
echo " ERROR: Migration failed"
echo ""
echo "Migration failed. Maintenance mode remains active."
echo "To restore:"
echo " 1. Fix migration issues"
echo " 2. Re-run migrations: docker exec $BACKEND_CONTAINER npm run migrate"
echo " 3. Run: $0 to retry, or manually restore traffic"
if [[ -x "$NOTIFY_SCRIPT" ]]; then
"$NOTIFY_SCRIPT" "failure" "Database migration failed during maintenance window. Manual intervention required." || true
fi
exit 1
fi
# Step 5: Restart backend containers
echo ""
echo "Step 5/6: Restarting backend containers..."
echo "----------------------------------------"
# Restart both backends to pick up any schema changes
docker restart mvp-backend-blue mvp-backend-green 2>/dev/null || true
# Wait for backends to be healthy
echo " Waiting for backends to be healthy..."
sleep 10
for container in mvp-backend-blue mvp-backend-green; do
for i in {1..12}; do
health=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "unknown")
if [[ "$health" == "healthy" ]]; then
echo " OK: $container is healthy"
break
fi
if [[ $i -eq 12 ]]; then
echo " WARNING: $container health check timeout"
fi
sleep 5
done
done
# Step 6: Disable maintenance mode
echo ""
echo "Step 6/6: Restoring traffic..."
echo "----------------------------------------"
# Restore traffic to active stack
if [[ "$ACTIVE_STACK" == "blue" ]]; then
sed -i -E "s/(name: mvp-frontend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
sed -i -E "s/(name: mvp-backend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
else
sed -i -E "s/(name: mvp-frontend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
sed -i -E "s/(name: mvp-backend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 100/" "$TRAEFIK_CONFIG"
fi
# Update state file
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
jq --arg ts "$TIMESTAMP" '.maintenance_mode = false | .last_migration = $ts' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
fi
# Wait for Traefik to pick up changes
sleep 3
echo " OK: Traffic restored"
# Send completion notification
if [[ -x "$NOTIFY_SCRIPT" ]]; then
"$NOTIFY_SCRIPT" "maintenance_end" "Maintenance window complete. Database migration successful." || true
fi
echo ""
echo "========================================"
echo "MAINTENANCE MIGRATION COMPLETE"
echo "========================================"
echo ""
echo "Summary:"
echo " - Migrations: Successful"
echo " - Active stack: $ACTIVE_STACK"
echo " - Maintenance mode: Disabled"
if [[ -n "$CREATE_BACKUP" ]]; then
echo " - Backup: $BACKUP_FILE"
fi
echo ""
exit 0

View File

@@ -0,0 +1,84 @@
#!/bin/bash
# Mirror upstream Docker images to GitLab Container Registry
# Run manually or via scheduled GitLab pipeline
# This avoids Docker Hub rate limits and ensures build reliability
set -euo pipefail
REGISTRY="${REGISTRY:-registry.motovaultpro.com/mirrors}"
# Base images required by MotoVaultPro
IMAGES=(
"node:20-alpine"
"nginx:alpine"
"postgres:18-alpine"
"redis:8.4-alpine"
"traefik:v3.6"
"docker:24.0"
"docker:24.0-dind"
)
echo "========================================"
echo "Base Image Mirroring Script"
echo "Registry: $REGISTRY"
echo "========================================"
# Check if logged into registry
if ! docker info 2>/dev/null | grep -q "Username"; then
echo "WARNING: Not logged into Docker registry"
echo "Run: docker login registry.motovaultpro.com"
fi
FAILED=()
SUCCESS=()
for img in "${IMAGES[@]}"; do
echo ""
echo "Processing: $img"
echo "----------------------------------------"
# Pull from upstream
echo " Pulling from upstream..."
if ! docker pull "$img"; then
echo " ERROR: Failed to pull $img"
FAILED+=("$img")
continue
fi
# Tag for local registry
local_tag="$REGISTRY/$img"
echo " Tagging as: $local_tag"
docker tag "$img" "$local_tag"
# Push to local registry
echo " Pushing to registry..."
if ! docker push "$local_tag"; then
echo " ERROR: Failed to push $local_tag"
FAILED+=("$img")
continue
fi
SUCCESS+=("$img")
echo " OK: $img mirrored successfully"
done
echo ""
echo "========================================"
echo "Summary"
echo "========================================"
echo "Successful: ${#SUCCESS[@]}"
for img in "${SUCCESS[@]}"; do
echo " - $img"
done
if [ ${#FAILED[@]} -gt 0 ]; then
echo ""
echo "Failed: ${#FAILED[@]}"
for img in "${FAILED[@]}"; do
echo " - $img"
done
exit 1
fi
echo ""
echo "All images mirrored successfully"

195
scripts/ci/notify.sh Executable file
View File

@@ -0,0 +1,195 @@
#!/bin/bash
# Deployment notification script using Resend API
# Sends email notifications for deployment events
#
# Usage: ./notify.sh <event_type> [message] [commit_sha]
# event_type: success, failure, rollback, rollback_failed, maintenance_start, maintenance_end
# message: Optional custom message
# commit_sha: Optional commit SHA for context
#
# Required environment variables:
# DEPLOY_NOTIFY_EMAIL - Recipient email address
#
# Reads Resend API key from:
# /run/secrets/resend-api-key (container)
# ./secrets/app/resend-api-key.txt (local)
#
# Exit codes:
# 0 - Notification sent (or skipped if not configured)
# 1 - Notification failed
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
EVENT_TYPE="${1:-}"
MESSAGE="${2:-}"
COMMIT_SHA="${3:-${CI_COMMIT_SHORT_SHA:-unknown}}"
if [[ -z "$EVENT_TYPE" ]]; then
echo "Usage: $0 <success|failure|rollback|rollback_failed|maintenance_start|maintenance_end> [message] [commit_sha]"
exit 1
fi
# Get recipient email
NOTIFY_EMAIL="${DEPLOY_NOTIFY_EMAIL:-}"
if [[ -z "$NOTIFY_EMAIL" ]]; then
echo "DEPLOY_NOTIFY_EMAIL not set, skipping notification"
exit 0
fi
# Get Resend API key
RESEND_API_KEY=""
if [[ -f "/run/secrets/resend-api-key" ]]; then
RESEND_API_KEY=$(cat /run/secrets/resend-api-key)
elif [[ -f "$PROJECT_ROOT/secrets/app/resend-api-key.txt" ]]; then
RESEND_API_KEY=$(cat "$PROJECT_ROOT/secrets/app/resend-api-key.txt")
fi
if [[ -z "$RESEND_API_KEY" ]]; then
echo "WARNING: Resend API key not found, skipping notification"
exit 0
fi
# Determine subject and styling based on event type
case "$EVENT_TYPE" in
"success")
SUBJECT="Deployment Successful - MotoVaultPro"
STATUS_COLOR="#22c55e"
STATUS_EMOJI="[OK]"
STATUS_TEXT="Deployment Successful"
DEFAULT_MESSAGE="Version $COMMIT_SHA is now live."
;;
"failure")
SUBJECT="Deployment Failed - MotoVaultPro"
STATUS_COLOR="#ef4444"
STATUS_EMOJI="[FAILED]"
STATUS_TEXT="Deployment Failed"
DEFAULT_MESSAGE="Deployment of $COMMIT_SHA failed. Check pipeline logs."
;;
"rollback")
SUBJECT="Auto-Rollback Executed - MotoVaultPro"
STATUS_COLOR="#f59e0b"
STATUS_EMOJI="[ROLLBACK]"
STATUS_TEXT="Rollback Executed"
DEFAULT_MESSAGE="Automatic rollback was triggered. Previous version restored."
;;
"rollback_failed")
SUBJECT="CRITICAL: Rollback Failed - MotoVaultPro"
STATUS_COLOR="#dc2626"
STATUS_EMOJI="[CRITICAL]"
STATUS_TEXT="Rollback Failed"
DEFAULT_MESSAGE="Rollback attempt failed. Manual intervention required immediately."
;;
"maintenance_start")
SUBJECT="Maintenance Mode Started - MotoVaultPro"
STATUS_COLOR="#6366f1"
STATUS_EMOJI="[MAINTENANCE]"
STATUS_TEXT="Maintenance Mode Active"
DEFAULT_MESSAGE="Application is in maintenance mode for database migration."
;;
"maintenance_end")
SUBJECT="Maintenance Mode Ended - MotoVaultPro"
STATUS_COLOR="#22c55e"
STATUS_EMOJI="[ONLINE]"
STATUS_TEXT="Maintenance Complete"
DEFAULT_MESSAGE="Maintenance window complete. Application is online."
;;
*)
echo "Unknown event type: $EVENT_TYPE"
exit 1
;;
esac
# Use custom message or default
FINAL_MESSAGE="${MESSAGE:-$DEFAULT_MESSAGE}"
TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
# Build HTML email
HTML_BODY=$(cat <<EOF
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f3f4f6; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { background: ${STATUS_COLOR}; color: white; padding: 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
.content { padding: 30px; }
.status { font-size: 18px; font-weight: 600; margin-bottom: 15px; }
.message { color: #374151; line-height: 1.6; margin-bottom: 20px; }
.details { background: #f9fafb; border-radius: 6px; padding: 15px; font-size: 14px; }
.details-row { display: flex; justify-content: space-between; margin-bottom: 8px; }
.details-label { color: #6b7280; }
.details-value { color: #111827; font-weight: 500; }
.footer { padding: 20px; text-align: center; color: #6b7280; font-size: 12px; border-top: 1px solid #e5e7eb; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>MotoVaultPro</h1>
</div>
<div class="content">
<div class="status">${STATUS_EMOJI} ${STATUS_TEXT}</div>
<div class="message">${FINAL_MESSAGE}</div>
<div class="details">
<div class="details-row">
<span class="details-label">Environment:</span>
<span class="details-value">Production</span>
</div>
<div class="details-row">
<span class="details-label">Commit:</span>
<span class="details-value">${COMMIT_SHA}</span>
</div>
<div class="details-row">
<span class="details-label">Time:</span>
<span class="details-value">${TIMESTAMP}</span>
</div>
</div>
</div>
<div class="footer">
MotoVaultPro CI/CD Notification System
</div>
</div>
</body>
</html>
EOF
)
# Send email via Resend API
echo "Sending notification: $EVENT_TYPE"
echo " To: $NOTIFY_EMAIL"
echo " Subject: $SUBJECT"
# Build JSON payload
JSON_PAYLOAD=$(cat <<EOF
{
"from": "MotoVaultPro <deploy@motovaultpro.com>",
"to": ["$NOTIFY_EMAIL"],
"subject": "$SUBJECT",
"html": $(echo "$HTML_BODY" | jq -Rs .)
}
EOF
)
# Send via Resend API
RESPONSE=$(curl -s -w "\n%{http_code}" \
-X POST \
-H "Authorization: Bearer $RESEND_API_KEY" \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD" \
"https://api.resend.com/emails")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [[ "$HTTP_CODE" == "200" ]] || [[ "$HTTP_CODE" == "201" ]]; then
echo " OK: Notification sent successfully"
exit 0
else
echo " ERROR: Failed to send notification (HTTP $HTTP_CODE)"
echo " Response: $BODY"
exit 1
fi

157
scripts/ci/switch-traffic.sh Executable file
View File

@@ -0,0 +1,157 @@
#!/bin/bash
# Traffic switching script for blue-green deployment
# Updates Traefik weighted routing configuration
#
# Usage: ./switch-traffic.sh <target_stack> [mode]
# target_stack: blue or green (the stack to switch TO)
# mode: instant (default) or gradual
#
# Gradual mode: 25% -> 50% -> 75% -> 100% with 3s intervals
#
# Exit codes:
# 0 - Traffic switch successful
# 1 - Switch failed
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
TARGET_STACK="${1:-}"
MODE="${2:-instant}"
if [[ -z "$TARGET_STACK" ]] || [[ ! "$TARGET_STACK" =~ ^(blue|green)$ ]]; then
echo "Usage: $0 <blue|green> [instant|gradual]"
exit 1
fi
TRAEFIK_CONFIG="$PROJECT_ROOT/config/traefik/dynamic/blue-green.yml"
STATE_FILE="$PROJECT_ROOT/config/deployment/state.json"
if [[ ! -f "$TRAEFIK_CONFIG" ]]; then
echo "ERROR: Traefik config not found: $TRAEFIK_CONFIG"
exit 1
fi
echo "========================================"
echo "Traffic Switch"
echo "Target Stack: $TARGET_STACK"
echo "Mode: $MODE"
echo "========================================"
# Determine source and target weights
if [[ "$TARGET_STACK" == "blue" ]]; then
SOURCE_STACK="green"
else
SOURCE_STACK="blue"
fi
# Function to update weights in Traefik config
update_weights() {
local blue_weight="$1"
local green_weight="$2"
echo " Setting weights: blue=$blue_weight, green=$green_weight"
# Use sed to update weights in the YAML file
# Frontend blue weight
sed -i.bak -E "s/(name: mvp-frontend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 $blue_weight/" "$TRAEFIK_CONFIG"
# Frontend green weight
sed -i.bak -E "s/(name: mvp-frontend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 $green_weight/" "$TRAEFIK_CONFIG"
# Backend blue weight
sed -i.bak -E "s/(name: mvp-backend-blue-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 $blue_weight/" "$TRAEFIK_CONFIG"
# Backend green weight
sed -i.bak -E "s/(name: mvp-backend-green-svc[[:space:]]+weight:)[[:space:]]+[0-9]+/\1 $green_weight/" "$TRAEFIK_CONFIG"
# Clean up backup files
rm -f "${TRAEFIK_CONFIG}.bak"
# Traefik watches the file and reloads automatically
# Give it a moment to pick up changes
sleep 1
}
# Verify Traefik has picked up the changes
verify_traefik_reload() {
# Give Traefik time to reload config
sleep 2
# Check if Traefik is still healthy
if docker exec mvp-traefik traefik healthcheck > /dev/null 2>&1; then
echo " OK: Traefik config reloaded"
return 0
else
echo " WARNING: Could not verify Traefik health"
return 0 # Don't fail on this, file watcher is reliable
fi
}
if [[ "$MODE" == "gradual" ]]; then
echo ""
echo "Gradual traffic switch starting..."
echo "----------------------------------------"
if [[ "$TARGET_STACK" == "blue" ]]; then
STEPS=("25 75" "50 50" "75 25" "100 0")
else
STEPS=("75 25" "50 50" "25 75" "0 100")
fi
step=1
for weights in "${STEPS[@]}"; do
blue_w=$(echo "$weights" | cut -d' ' -f1)
green_w=$(echo "$weights" | cut -d' ' -f2)
echo ""
echo "Step $step/4: blue=$blue_w%, green=$green_w%"
update_weights "$blue_w" "$green_w"
verify_traefik_reload
if [[ $step -lt 4 ]]; then
echo " Waiting 3 seconds before next step..."
sleep 3
fi
step=$((step + 1))
done
else
# Instant switch
echo ""
echo "Instant traffic switch..."
echo "----------------------------------------"
if [[ "$TARGET_STACK" == "blue" ]]; then
update_weights 100 0
else
update_weights 0 100
fi
verify_traefik_reload
fi
# Update state file
echo ""
echo "Updating deployment state..."
echo "----------------------------------------"
if [[ -f "$STATE_FILE" ]] && command -v jq &> /dev/null; then
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
jq --arg active "$TARGET_STACK" \
--arg inactive "$SOURCE_STACK" \
--arg ts "$TIMESTAMP" \
'.active_stack = $active | .inactive_stack = $inactive | .last_switch = $ts' \
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
echo " Active stack: $TARGET_STACK"
echo " Inactive stack: $SOURCE_STACK"
fi
echo ""
echo "========================================"
echo "Traffic Switch COMPLETE"
echo "All traffic now routed to: $TARGET_STACK"
echo "========================================"
exit 0