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
# GitLab 18.6+ with shell executor
# MotoVaultPro GitLab CI/CD Pipeline - Blue-Green Deployment
# GitLab 18.6+ with separate build and production runners
# See docs/CICD-DEPLOY.md for complete documentation
# v1.5
# v2.0 - Blue-Green with Auto-Rollback
stages:
- validate
- build
- deploy
- deploy-prepare
- deploy-switch
- verify
- rollback
- notify
variables:
# Use stable clone path instead of runner-specific path
GIT_CLONE_PATH: $CI_BUILDS_DIR/motovaultpro
DEPLOY_PATH: $CI_BUILDS_DIR/motovaultpro
DOCKER_COMPOSE_FILE: docker-compose.yml
DOCKER_COMPOSE_PROD_FILE: docker-compose.prod.yml
# Registry configuration
REGISTRY: registry.motovaultpro.com
REGISTRY_MIRRORS: ${REGISTRY}/mirrors
IMAGE_TAG: ${CI_COMMIT_SHORT_SHA}
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:
after_script:
- echo "Fixing file permissions..."
- 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:
stage: validate
tags:
- production
- shell
only:
- main
script:
@@ -32,129 +52,385 @@ validate:
- echo "Validating deployment prerequisites..."
- echo "=========================================="
- echo "Checking Docker..."
- 'docker info > /dev/null 2>&1 || (echo "ERROR: Docker not accessible" && exit 1)'
- echo "OK Docker is accessible"
- docker info > /dev/null 2>&1 || (echo "ERROR - Docker not accessible" && exit 1)
- echo "OK - Docker is accessible"
- echo "Checking Docker Compose..."
- 'docker compose version > /dev/null 2>&1 || (echo "ERROR: Docker Compose not available" && exit 1)'
- echo "OK Docker Compose is available"
- docker compose version > /dev/null 2>&1 || (echo "ERROR - Docker Compose not available" && exit 1)
- echo "OK - Docker Compose is available"
- echo "Checking deployment path..."
- 'test -d "$DEPLOY_PATH" || (echo "ERROR: DEPLOY_PATH not found" && exit 1)'
- echo "OK Deployment path exists"
- test -d "$DEPLOY_PATH" || (echo "ERROR - DEPLOY_PATH not found" && exit 1)
- 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 "Validation complete"
- 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:
stage: build
tags:
- build
only:
- main
script:
- echo "Authenticating with GitLab Container Registry..."
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$REGISTRY"
- echo "=========================================="
- echo "Building Docker images..."
- echo "Commit - ${CI_COMMIT_SHORT_SHA}"
- echo "Backend - ${BACKEND_IMAGE}"
- echo "Frontend - ${FRONTEND_IMAGE}"
- echo "=========================================="
- cd "$DEPLOY_PATH"
- echo "Building images..."
- docker compose -f $DOCKER_COMPOSE_FILE build --no-cache
# Build backend
- 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 "Build complete"
- echo "=========================================="
# Deploy Stage - Inject secrets and deploy services
deploy:
stage: deploy
# ============================================
# Stage 3: DEPLOY-PREPARE
# Pull images, start inactive stack, run health checks
# ============================================
deploy-prepare:
stage: deploy-prepare
tags:
- production
- shell
only:
- main
needs:
- job: validate
artifacts: true
- job: build
environment:
name: production
url: https://motovaultpro.com
script:
- echo "=========================================="
- echo "Deploying MotoVaultPro..."
- echo "Preparing deployment to ${TARGET_STACK} stack..."
- echo "=========================================="
- 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
- ./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
- echo "Step 3/7 Pulling base images..."
- docker compose -f $DOCKER_COMPOSE_FILE pull
- echo "Step 4/7 Starting database services..."
- docker compose -f $DOCKER_COMPOSE_FILE -f $DOCKER_COMPOSE_PROD_FILE up -d mvp-postgres mvp-redis
- echo "Waiting for database to be ready..."
- sleep 15
- echo "Step 5/7 Running database migrations..."
- docker compose -f $DOCKER_COMPOSE_FILE run --rm mvp-backend npm run migrate || echo "Migration skipped"
- echo "Step 6/7 Running vehicle ETL import..."
# Initialize data directories
- echo "Step 2/5 - Initializing data directories..."
- sudo mkdir -p data/backups data/documents
- sudo chown -R 1001:1001 data/backups data/documents
- sudo chmod 755 data/backups data/documents
# Pull new images
- echo "Step 3/5 - Pulling images..."
- docker pull ${BACKEND_IMAGE}
- 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
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;"
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < data/vehicle-etl/output/01_engines.sql
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < data/vehicle-etl/output/02_transmissions.sql
docker exec -i mvp-postgres psql -U postgres -d motovaultpro < data/vehicle-etl/output/03_vehicle_options.sql
- echo "Flushing Redis cache..."
- docker exec mvp-redis redis-cli FLUSHALL
- echo "Vehicle ETL import completed"
- echo "Step 7/7 Starting all services..."
- docker compose -f $DOCKER_COMPOSE_FILE -f $DOCKER_COMPOSE_PROD_FILE up -d
- echo "Waiting for services to initialize..."
- sleep 30
export BACKEND_IMAGE=${BACKEND_IMAGE}
export FRONTEND_IMAGE=${FRONTEND_IMAGE}
docker compose -f $COMPOSE_FILE -f $COMPOSE_BLUE_GREEN up -d \
mvp-frontend-${TARGET_STACK} mvp-backend-${TARGET_STACK}
# Wait for stack to be ready
- echo "Step 5/5 - Waiting for stack health..."
- sleep 10
# Run health check
- echo "Running health check on ${TARGET_STACK} stack..."
- 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 "Deployment complete"
- echo "Deploy preparation complete"
- echo "=========================================="
# Verify Stage - Health checks
verify:
stage: verify
# ============================================
# Stage 4: DEPLOY-SWITCH
# Switch traffic to new stack
# ============================================
deploy-switch:
stage: deploy-switch
tags:
- production
- shell
only:
- main
needs:
- job: validate
artifacts: true
- job: deploy-prepare
script:
- echo "=========================================="
- echo "Verifying deployment..."
- echo "Switching traffic to ${TARGET_STACK} stack..."
- echo "=========================================="
- 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
for service in mvp-traefik mvp-frontend mvp-backend mvp-postgres mvp-redis; do
status=$(docker inspect --format='{{.State.Status}}' $service 2>/dev/null || echo "not found")
if [ "$status" != "running" ]; then
echo "ERROR: $service is not running (status: $status)"
docker logs $service --tail 50 2>/dev/null || true
FAILED=1
else
echo "OK: $service is running"
fi
done
if [ $FAILED -eq 1 ]; then
echo "One or more services failed to start"
exit 1
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 commit "$CI_COMMIT_SHORT_SHA" \
--arg ts "$TIMESTAMP" \
'.last_deployment = $ts | .last_deployment_commit = $commit | .last_deployment_status = "success" | .rollback_available = true' \
"$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
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
if docker exec mvp-backend curl -sf http://localhost:3001/health > /dev/null 2>&1; then
echo "OK: Backend health check passed"
HEALTH_OK=1
if curl -sf https://motovaultpro.com/api/health > /dev/null 2>&1; then
echo "OK - External health check passed"
break
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
done
if [ $HEALTH_OK -eq 0 ]; then
echo "ERROR: Backend health check failed after 6 attempts"
docker logs mvp-backend --tail 100
exit 1
fi
- echo "Checking frontend..."
# Verify container status
- echo "Checking container status..."
- |
if docker compose -f $DOCKER_COMPOSE_FILE exec -T mvp-frontend curl -sf http://localhost:3000 > /dev/null 2>&1; then
echo "OK: Frontend is accessible"
else
echo "WARNING: Frontend check failed (might need Traefik routing)"
fi
for service in mvp-frontend-${TARGET_STACK} mvp-backend-${TARGET_STACK}; do
status=$(docker inspect --format='{{.State.Status}}' $service 2>/dev/null || echo "not found")
health=$(docker inspect --format='{{.State.Health.Status}}' $service 2>/dev/null || echo "unknown")
if [ "$status" != "running" ] || [ "$health" != "healthy" ]; then
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 "Deployment verified successfully!"
- echo "Version ${CI_COMMIT_SHORT_SHA} is now live"
- 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
# 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
FROM node:lts-alpine AS builder
FROM ${REGISTRY_MIRRORS}/node:20-alpine AS builder
# Install build dependencies
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
WORKDIR /app
# Copy package files
COPY package*.json ./
# Copy package files from backend directory
COPY backend/package*.json ./
# Install all dependencies (including dev for building)
RUN npm install && npm cache clean --force
# Copy source code
COPY . .
# Copy logo from frontend for email templates (needed for build)
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
# 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)
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
# 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 ./
# 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/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
RUN chown -R nodejs:nodejs /app
@@ -64,8 +77,8 @@ EXPOSE 3001
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))"
# Use dumb-init for proper signal handling
ENTRYPOINT ["dumb-init", "--"]
# Use dumb-init with entrypoint for permission checks
ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"]
# Run production application with auto-migrate (idempotent)
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> {
const previewId = uuidv4();
const toCreate: ImportRow[] = [];
const toUpdate: ImportRow[] = [];
const toUpdate: ImportRow[] = []; // Kept for interface compatibility (will be empty)
const errors: ImportError[] = [];
const lines = csvContent.trim().split('\n');
@@ -146,21 +146,8 @@ export class CatalogImportService {
transmissionType,
};
// Check if record exists to determine create vs update (upsert logic)
const existsResult = await this.pool.query(
`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);
}
// All rows will be inserted with ON CONFLICT handling (proper upsert)
toCreate.push(row);
} catch (error: any) {
errors.push({ row: rowNum, error: error.message || 'Parse error' });
}
@@ -239,61 +226,29 @@ export class CatalogImportService {
transmissionId = transResult.rows[0].id;
}
// Insert vehicle option
await client.query(
// Upsert vehicle option (insert or update if exists)
const upsertResult = await client.query(
`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]
);
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) {
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
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}` });
}
}
// Note: Separate "Process updates" loop removed - ON CONFLICT handles both INSERT and UPDATE
await client.query('COMMIT');
@@ -306,13 +261,23 @@ export class CatalogImportService {
logger.debug('Vehicle data cache invalidated after import');
}
logger.info('Catalog import completed', {
previewId,
created: result.created,
updated: result.updated,
errors: result.errors.length,
changedBy,
});
// Log completion with appropriate level
if (result.errors.length > 0) {
logger.warn('Catalog import completed with errors', {
previewId,
created: result.created,
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;
} catch (error) {

View File

@@ -5,6 +5,7 @@
import { FastifyRequest, FastifyReply } from 'fastify';
import { Pool } from 'pg';
import { logger } from '../../../core/logging/logger';
import { BackupService } from '../domain/backup.service';
import { BackupRestoreService } from '../domain/backup-restore.service';
import {
@@ -179,8 +180,14 @@ export class BackupController {
const preview = await this.restoreService.previewRestore(request.params.id);
reply.send(preview);
} 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({
error: error instanceof Error ? error.message : 'Failed to preview restore',
error: errorMessage,
});
}
}
@@ -192,7 +199,7 @@ export class BackupController {
try {
const result = await this.restoreService.executeRestore({
backupId: request.params.id,
createSafetyBackup: request.body.createSafetyBackup,
createSafetyBackup: request.body?.createSafetyBackup ?? true,
});
if (result.success) {
@@ -211,9 +218,15 @@ export class BackupController {
});
}
} 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({
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
* @param to Recipient email address
* @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
*/
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}`);
}
}
/**
* 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,
body: string,
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 {
subject: this.templateService.render(subject, variables),
body: this.templateService.render(body, variables),
subject: renderedSubject,
body: renderedBody,
html: renderedHtml,
};
}
@@ -130,10 +135,10 @@ export class NotificationsService {
};
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 {
await this.emailService.sendText(userEmail, subject, body);
await this.emailService.send(userEmail, subject, htmlBody);
await this.repository.insertNotificationLog({
user_id: userId,
@@ -188,10 +193,10 @@ export class NotificationsService {
};
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 {
await this.emailService.sendText(userEmail, subject, body);
await this.emailService.send(userEmail, subject, htmlBody);
await this.repository.insertNotificationLog({
user_id: userId,
@@ -249,9 +254,10 @@ export class NotificationsService {
const subject = this.templateService.render(template.subject, sampleVariables);
const body = this.templateService.render(template.body, sampleVariables);
const htmlBody = this.templateService.renderEmailHtml(template.body, sampleVariables);
try {
await this.emailService.sendText(recipientEmail, `[TEST] ${subject}`, body);
await this.emailService.send(recipientEmail, `[TEST] ${subject}`, htmlBody);
return {
subject,

View File

@@ -3,6 +3,9 @@
* @ai-context Replaces {{variableName}} with values
*/
import { renderEmailLayout } from './email-layout/base-layout';
import { EMAIL_STYLES } from './email-layout/email-styles';
export class TemplateService {
/**
* Render a template string by replacing {{variableName}} with values
@@ -22,6 +25,38 @@ export class TemplateService {
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
* @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 { logger } from './core/logging/logger';
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;
@@ -13,6 +15,15 @@ async function start() {
try {
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({
port: PORT,
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
# Network auto-discovery - Traefik will use the networks it's connected to
file:
filename: /etc/traefik/middleware.yml
# Watch directory for dynamic configuration (blue-green routing, middleware)
directory: /etc/traefik/dynamic
watch: true
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:
# Traefik - Service Discovery and Load Balancing
mvp-traefik:
image: traefik:v3.6
image: ${REGISTRY_MIRRORS:-registry.motovaultpro.com/mirrors}/traefik:v3.6
container_name: mvp-traefik
restart: unless-stopped
command:
@@ -15,7 +19,7 @@ services:
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
@@ -86,8 +90,8 @@ services:
# Application Services - Backend API
mvp-backend:
build:
context: ./backend
dockerfile: Dockerfile
context: .
dockerfile: backend/Dockerfile
cache_from:
- node:lts-alpine
container_name: mvp-backend
@@ -154,7 +158,7 @@ services:
# Database Services - Application PostgreSQL
mvp-postgres:
image: postgres:18-alpine
image: ${REGISTRY_MIRRORS:-registry.motovaultpro.com/mirrors}/postgres:18-alpine
container_name: mvp-postgres
restart: unless-stopped
environment:
@@ -179,7 +183,7 @@ services:
# Database Services - Application Redis
mvp-redis:
image: redis:8.4-alpine
image: ${REGISTRY_MIRRORS:-registry.motovaultpro.com/mirrors}/redis:8.4-alpine
container_name: mvp-redis
restart: unless-stopped
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
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
1. [Prerequisites](#prerequisites)
2. [GitLab Runner Setup](#gitlab-runner-setup)
3. [CI/CD Variables Configuration](#cicd-variables-configuration)
4. [Secrets Architecture](#secrets-architecture)
5. [Pipeline Overview](#pipeline-overview)
6. [Deployment Process](#deployment-process)
7. [Rollback Procedure](#rollback-procedure)
8. [Troubleshooting](#troubleshooting)
1. [Architecture Overview](#architecture-overview)
2. [Prerequisites](#prerequisites)
3. [Pipeline Stages](#pipeline-stages)
4. [Blue-Green Deployment](#blue-green-deployment)
5. [CI/CD Variables Configuration](#cicd-variables-configuration)
6. [Container Registry](#container-registry)
7. [Deployment Process](#deployment-process)
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
- Linux server with Docker Engine installed
- Docker Compose v2 (plugin version)
- GitLab Runner installed and registered
- Git installed
- curl installed (for health checks)
| Server | Purpose | Specs | Runner Tags |
|--------|---------|-------|-------------|
| Build VPS | Docker image builds | 2 CPU, 4GB RAM | `build` |
| Prod Server | Application hosting | 8GB+ RAM | `production` |
### GitLab Requirements
See [BUILD-SERVER-SETUP.md](BUILD-SERVER-SETUP.md) for build server setup.
- GitLab 18.6+ (tested with 18.6.2)
- Project with CI/CD enabled
- Protected `main` branch
- Maintainer access for CI/CD variable configuration
### Software Requirements
- GitLab 18.6+
- 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:
```bash
# Add gitlab-runner to docker group (if not already done)
sudo usermod -aG docker gitlab-runner
# Verify access
sudo -u gitlab-runner docker info
sudo -u gitlab-runner docker compose version
```
[Push to main]
|
v
[validate] - Checks Docker, paths, registry
|
v
[build] - Builds backend + frontend images
| 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
# Create deployment directory
sudo mkdir -p /opt/motovaultpro
sudo chown gitlab-runner:gitlab-runner /opt/motovaultpro
### Stack Configuration
# Clone repository (first time only)
sudo -u gitlab-runner git clone <repository-url> /opt/motovaultpro
Both stacks share the same database layer:
| 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.
### 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 Name | Type | Protected | Masked | Description |
|--------------|------|-----------|--------|-------------|
| `POSTGRES_PASSWORD` | File | Yes | Yes | PostgreSQL database password |
| `AUTH0_CLIENT_SECRET` | File | Yes | Yes | Auth0 client secret for backend |
| `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)
| Variable | Type | Protected | Purpose |
|----------|------|-----------|---------|
| `DEPLOY_NOTIFY_EMAIL` | Variable | Yes | Notification recipient |
| `VITE_AUTH0_DOMAIN` | Variable | No | Auth0 domain |
| `VITE_AUTH0_CLIENT_ID` | Variable | No | Auth0 client ID |
| `VITE_AUTH0_AUDIENCE` | Variable | No | Auth0 audience |
### Secret Files
```
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
```
These use GitLab's **File** type and are injected via `scripts/inject-secrets.sh`:
### 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`)
- File permissions (600) restrict access
- Masked variables prevent accidental log exposure
- Protected variables only available on protected branches
### Registry Authentication
GitLab provides these automatically:
- `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
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
### Registry URL
```
[Validate] -> [Build] -> [Deploy] -> [Verify]
| | | |
Check Build Inject Health
prereqs images secrets checks
|
Migrate
|
Start
services
registry.motovaultpro.com
```
### 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
### Automatic Deployment
Deployments are triggered automatically when:
- Code is pushed to the `main` branch
- A merge request is merged into `main`
Deployments trigger automatically on push to `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
To trigger a manual deployment:
1. Go to **CI/CD > Pipelines**
2. Click **Run pipeline**
3. Select the `main` branch
3. Select `main` branch
4. Click **Run pipeline**
### Deployment Steps (What Happens)
### Deployment Timeline
1. **Secrets Injection**
- `inject-secrets.sh` copies GitLab File variables to `secrets/app/`
- Permissions are set to 600 for security
2. **Service Shutdown**
- Existing containers are stopped gracefully (30s timeout)
- Volumes are preserved
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
| Phase | Duration |
|-------|----------|
| Validate | ~5s |
| Build | ~2 min |
| Deploy-prepare | ~30s |
| Deploy-switch | ~3s |
| Verify | ~30s |
| **Total** | ~3 min |
---
## Rollback Procedure
## Rollback Procedures
### 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
Use the rollback script:
SSH to production server:
```bash
# SSH to server
ssh user@server
# Run rollback to previous commit
cd /opt/motovaultpro
./scripts/rollback.sh HEAD~1
# Or rollback to specific tag/commit
./scripts/rollback.sh v1.0.0
# Check current state
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
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
cd /opt/motovaultpro
# Stop everything
docker compose down
# With backup
./scripts/ci/maintenance-migrate.sh backup
# Check git history
git log --oneline -10
# Without backup
./scripts/ci/maintenance-migrate.sh
```
# Checkout known working version
git checkout <commit-hash>
### What Happens
# Rebuild and start
docker compose build
docker compose up -d
1. Sends maintenance notification
2. Enables maintenance mode (stops traffic)
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
### 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
**Symptom**: Docker build errors
**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**:
**Check build server connectivity:**
```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
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
docker network ls
docker network inspect motovaultpro_backend
docker logs mvp-backend-blue --tail 100
docker logs mvp-frontend-blue --tail 100
```
### Viewing Logs
**Health check timeout:**
```bash
# All services
docker compose logs -f
# Specific service
docker compose logs -f mvp-backend
# Last 100 lines
docker compose logs --tail 100 mvp-backend
# Increase timeout in .gitlab-ci.yml
HEALTH_CHECK_TIMEOUT: "90"
```
---
## 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:
### Traffic Not Switching
**Check Traefik config:**
```bash
# Manual backup
docker compose exec mvp-postgres pg_dump -U postgres motovaultpro > backup.sql
# 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
cat config/traefik/dynamic/blue-green.yml
docker exec mvp-traefik traefik healthcheck
```
### Monitoring
**Check routing:**
```bash
curl -I https://motovaultpro.com/api/health
```
Consider adding:
- Prometheus metrics (Traefik already configured)
- Health check alerts
- Log aggregation
### Verify Stage Fails
**Check external connectivity:**
```bash
curl -sf https://motovaultpro.com/api/health
```
**Check container health:**
```bash
docker inspect --format='{{.State.Health.Status}}' mvp-backend-blue
```
---
## 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
| Path | Description |
|------|-------------|
| `$CI_BUILDS_DIR/motovaultpro` | Application root (stable clone path) |
| `$CI_BUILDS_DIR/motovaultpro/secrets/app/` | Secrets directory |
| `$CI_BUILDS_DIR/motovaultpro/data/documents/` | Document storage |
| `$CI_BUILDS_DIR/motovaultpro/config/` | Configuration files |
| `config/deployment/state.json` | Deployment state |
| `config/traefik/dynamic/blue-green.yml` | Traffic routing |
| `scripts/ci/` | Deployment scripts |
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 |
|-----------|---------|
| `mvp-traefik` | Reverse proxy, TLS termination |
| `mvp-frontend` | React SPA |
| `mvp-backend` | Node.js API |
| `mvp-postgres` | PostgreSQL database |
| `mvp-redis` | Redis cache |
# Check container status
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Health}}"
# View logs
docker logs mvp-backend-blue -f
# 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.
- Ask clarifying questions.
- 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 ***
- 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.
- There is an error when you try and create a backup.
- Start with this file. /Users/egullickson/Documents/Technology/coding/motovaultpro/backend/src/features/admin/backup/api/backup.controller.ts
- There are permission errors with the backup files.
- 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 ***
- 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.
- Ask clarifying questions.
- 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 ***
- 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 ***
- 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
- Whatever process is running today only goes up to model year 2022.
- All the existing SQL files setup for import can be replaced with new ones created from the running mvp-postres data.
- Research this code base and ask iterative questions to compile a complete plan.
- We will pair plan this. Ask me for options for various levels of redundancy and automation
@@ -97,14 +103,17 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
- Make no assumptions.
- Ask clarifying questions.
- 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 ***
- 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.
- Currently the onboarding drop downs are washed out when using the light theme. See image.
- The colors need to change to have more contrast but retain the MUI theme for drop down.
- Start your research at this route https://motovaultpro.com/garage/settings/admin/email-templates
- 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 ***
- Research this code base and ask iterative questions to compile a complete plan.
- The URL is here. https://motovaultpro.com/onboarding
- Research this code base and ask iterative questions to compile a complete plan.

View File

@@ -1,7 +1,11 @@
# Production Dockerfile for MotoVaultPro Frontend
# Uses mirrored base images from GitLab Container Registry
# Stage 1: Base with dependencies
FROM node:lts-alpine AS base
# 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
FROM ${REGISTRY_MIRRORS}/node:20-alpine AS base
RUN apk add --no-cache dumb-init curl
WORKDIR /app
COPY package*.json ./
@@ -29,7 +33,7 @@ COPY . .
RUN npm run build
# Stage 4: Production stage with nginx
FROM nginx:alpine AS production
FROM ${REGISTRY_MIRRORS}/nginx:alpine AS production
# Add curl for healthchecks
RUN apk add --no-cache curl

View File

@@ -4,7 +4,36 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark">
<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 -->
<script src="/config.js"></script>
</head>

View File

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

View File

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

View File

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

View File

@@ -610,7 +610,7 @@ export const AdminBackupMobileScreen: React.FC = () => {
{showRestoreConfirm && (
<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>
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
<p className="text-xs text-red-800">
@@ -988,7 +988,7 @@ export const AdminBackupMobileScreen: React.FC = () => {
{/* Delete Backup Confirmation Modal */}
{showDeleteBackupConfirm && (
<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>
<p className="text-sm text-slate-600 mb-4">
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 */}
{showDeleteScheduleConfirm && (
<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>
<p className="text-sm text-slate-600 mb-4">
Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be

View File

@@ -13,6 +13,8 @@ import {
MoreVert,
Close,
History,
ExpandMore,
ExpandLess,
} from '@mui/icons-material';
import toast from 'react-hot-toast';
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
@@ -29,6 +31,7 @@ import { adminApi } from '../api/admin.api';
import {
CatalogSearchResult,
ImportPreviewResult,
ImportApplyResult,
} from '../types/admin.types';
export const AdminCatalogMobileScreen: React.FC = () => {
@@ -54,6 +57,8 @@ export const AdminCatalogMobileScreen: React.FC = () => {
// Import state
const [importSheet, setImportSheet] = useState(false);
const [importPreview, setImportPreview] = useState<ImportPreviewResult | null>(null);
const [importResult, setImportResult] = useState<ImportApplyResult | null>(null);
const [errorsExpanded, setErrorsExpanded] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Hooks
@@ -144,15 +149,38 @@ export const AdminCatalogMobileScreen: React.FC = () => {
if (!importPreview?.previewId) return;
try {
await importApplyMutation.mutateAsync(importPreview.previewId);
setImportSheet(false);
setImportPreview(null);
const result = await importApplyMutation.mutateAsync(importPreview.previewId);
setImportResult(result);
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();
} catch {
// Error handled by mutation
// Error handled by mutation's onError
}
}, [importPreview, importApplyMutation, refetch]);
const handleImportSheetClose = useCallback(() => {
if (importApplyMutation.isPending) return;
setImportSheet(false);
setImportPreview(null);
setImportResult(null);
setErrorsExpanded(false);
}, [importApplyMutation.isPending]);
// Export handler
const handleExport = useCallback(() => {
setMenuOpen(false);
@@ -351,14 +379,14 @@ export const AdminCatalogMobileScreen: React.FC = () => {
{menuOpen && (
<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-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()}
>
<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
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' }}
>
<Close />
@@ -368,7 +396,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
<button
onClick={handleImportClick}
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' }}
>
<FileUpload />
@@ -378,7 +406,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
<button
onClick={handleExport}
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' }}
>
<FileDownload />
@@ -387,7 +415,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
<button
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' }}
>
Cancel
@@ -399,9 +427,9 @@ export const AdminCatalogMobileScreen: React.FC = () => {
{/* Delete Confirmation Sheet */}
{deleteSheet.open && deleteSheet.item && (
<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">
<h2 className="text-xl font-bold text-slate-800">Delete Configuration?</h2>
<p className="text-slate-600">
<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 dark:text-avus">Delete Configuration?</h2>
<p className="text-slate-600 dark:text-titanio">
Are you sure you want to delete{' '}
<strong>
{deleteSheet.item.year} {deleteSheet.item.make} {deleteSheet.item.model}{' '}
@@ -413,7 +441,7 @@ export const AdminCatalogMobileScreen: React.FC = () => {
<button
onClick={() => setDeleteSheet({ open: false, item: null })}
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' }}
>
Cancel
@@ -435,17 +463,16 @@ export const AdminCatalogMobileScreen: React.FC = () => {
</div>
)}
{/* Import Preview Sheet */}
{importSheet && importPreview && (
{/* Import Preview/Results Sheet */}
{importSheet && (importPreview || importResult) && (
<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">
<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
onClick={() => {
setImportSheet(false);
setImportPreview(null);
}}
onClick={handleImportSheetClose}
disabled={importApplyMutation.isPending}
className="p-2 text-slate-500 hover:text-slate-700"
style={{ minHeight: '44px', minWidth: '44px' }}
@@ -454,74 +481,127 @@ export const AdminCatalogMobileScreen: React.FC = () => {
</button>
</div>
{/* Summary */}
<div className="flex gap-4 text-sm">
<div className="bg-green-100 text-green-800 px-3 py-2 rounded-lg">
<strong>{importPreview.toCreate.length}</strong> to create
</div>
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-lg">
<strong>{importPreview.toUpdate.length}</strong> to update
</div>
</div>
{/* Preview Mode */}
{importPreview && !importResult && (
<>
<div className="flex gap-4 text-sm">
<div className="bg-green-100 text-green-800 px-3 py-2 rounded-lg">
<strong>{importPreview.toCreate.length}</strong> to create
</div>
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-lg">
<strong>{importPreview.toUpdate.length}</strong> to update
</div>
</div>
{/* Errors */}
{importPreview.errors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-red-800 font-semibold mb-2">
{importPreview.errors.length} Error(s) Found:
</p>
<ul className="text-red-700 text-sm space-y-1">
{importPreview.errors.slice(0, 5).map((err, idx) => (
<li key={idx}>
Row {err.row}: {err.error}
</li>
))}
{importPreview.errors.length > 5 && (
<li>...and {importPreview.errors.length - 5} more errors</li>
)}
</ul>
</div>
{importPreview.errors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-red-800 font-semibold mb-2">
{importPreview.errors.length} Error(s) Found:
</p>
<ul className="text-red-700 text-sm space-y-1">
{importPreview.errors.slice(0, 5).map((err, idx) => (
<li key={idx}>
Row {err.row}: {err.error}
</li>
))}
{importPreview.errors.length > 5 && (
<li>...and {importPreview.errors.length - 5} more errors</li>
)}
</ul>
</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 */}
{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>
{/* Results Mode */}
{importResult && (
<>
<div className="flex gap-4 text-sm">
<div className="bg-green-100 text-green-800 px-3 py-2 rounded-lg">
<strong>{importResult.created}</strong> created
</div>
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-lg">
<strong>{importResult.updated}</strong> updated
</div>
</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">
<button
onClick={() => {
setImportSheet(false);
setImportPreview(null);
}}
onClick={handleImportSheetClose}
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"
style={{ minHeight: '44px' }}
>
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'
)}
{importResult ? 'Close' : 'Cancel'}
</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>

View File

@@ -38,6 +38,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
const [editIsActive, setEditIsActive] = useState(true);
const [previewSubject, setPreviewSubject] = useState('');
const [previewBody, setPreviewBody] = useState('');
const [previewHtml, setPreviewHtml] = useState('');
const [showHtmlPreview, setShowHtmlPreview] = useState(false);
// Queries
const { data: templates, isLoading } = useQuery({
@@ -66,6 +68,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
onSuccess: (data) => {
setPreviewSubject(data.subject);
setPreviewBody(data.body);
setPreviewHtml(data.html || '');
},
onError: () => {
toast.error('Failed to generate preview');
@@ -117,6 +120,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
setPreviewTemplate(null);
setPreviewSubject('');
setPreviewBody('');
setPreviewHtml('');
setShowHtmlPreview(false);
}, []);
const handleSave = useCallback(() => {
@@ -238,7 +243,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
{editingTemplate.variables.map((variable) => (
<span
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}}}`}
</span>
@@ -250,7 +255,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
<div className="flex gap-2 pt-2">
<button
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
</button>
@@ -304,6 +309,23 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
</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>
<label className="block text-sm font-medium text-slate-700 mb-1">
Subject
@@ -313,14 +335,29 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Body
</label>
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
{previewBody}
{showHtmlPreview ? (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
HTML Preview
</label>
<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>
<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 (
<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">
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3>
<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 dark:text-avus mb-4">{title}</h3>
{children}
<div className="flex justify-end gap-2 mt-4">
{actions || (
@@ -337,7 +337,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
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 && (
<button
@@ -378,7 +378,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
<select
value={params.tier || ''}
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="free">Free</option>
@@ -393,7 +393,7 @@ export const AdminUsersMobileScreen: React.FC = () => {
<select
value={params.status || '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="active">Active</option>

View File

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

View File

@@ -114,7 +114,7 @@ export const VerifyEmailPage: React.FC = () => {
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="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="mx-auto w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mb-4">
<svg
@@ -131,8 +131,8 @@ export const VerifyEmailPage: React.FC = () => {
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-800 mb-2">Check Your Email</h1>
<p className="text-gray-600">
<h1 className="text-2xl font-bold text-gray-800 dark:text-avus mb-2">Check Your Email</h1>
<p className="text-gray-600 dark:text-titanio">
We've sent a verification link to
</p>
{email && (
@@ -143,7 +143,7 @@ export const VerifyEmailPage: React.FC = () => {
</div>
<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>Once verified, you can log in to complete your profile setup.</p>
</div>

View File

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

View File

@@ -188,10 +188,16 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
InputProps={{
readOnly: true,
sx: (theme) => ({
backgroundColor: theme.palette.mode === 'dark' ? '#4C4E4D' : 'grey.50',
backgroundColor: 'grey.50',
...theme.applyStyles('dark', {
backgroundColor: '#4C4E4D',
}),
'& .MuiOutlinedInput-input': {
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) => ({
'& .MuiAutocomplete-groupLabel': {
fontWeight: 600,
backgroundColor: theme.palette.mode === 'dark' ? '#4C4E4D' : 'grey.100',
backgroundColor: 'grey.100',
...theme.applyStyles('dark', {
backgroundColor: '#4C4E4D',
}),
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.5px'

View File

@@ -36,13 +36,13 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
return (
<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">
<h3 className="text-xl font-semibold text-slate-800 mb-4">Delete Account</h3>
<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 dark:text-avus mb-4">Delete Account</h3>
{/* Warning Alert */}
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<p className="font-semibold text-amber-900 mb-2">30-Day Grace Period</p>
<p className="text-sm text-amber-800">
<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 dark:text-amber-200 mb-2">30-Day Grace Period</p>
<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
the grace period by logging back in.
</p>
@@ -50,7 +50,7 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
{/* Confirmation Input */}
<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
</label>
<input
@@ -58,14 +58,14 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
value={confirmationText}
onChange={(e) => setConfirmationText(e.target.value)}
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'
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-slate-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 dark:border-silverstone focus:ring-red-500 focus:border-red-500'
}`}
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>
{/* Action Buttons */}
@@ -73,7 +73,7 @@ export const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({ isOpen,
<button
onClick={onClose}
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' }}
>
Cancel

View File

@@ -25,9 +25,9 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
}) => (
<div className="flex items-center justify-between py-2">
<div>
<p className="font-medium text-slate-800">{label}</p>
<p className="font-medium text-slate-800 dark:text-avus">{label}</p>
{description && (
<p className="text-sm text-slate-500">{description}</p>
<p className="text-sm text-slate-500 dark:text-titanio">{description}</p>
)}
</div>
<button
@@ -56,14 +56,14 @@ const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;
return (
<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">
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3>
<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 dark:bg-scuro rounded-lg p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">{title}</h3>
{children}
<div className="flex justify-end mt-4">
<button
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
</button>
@@ -184,8 +184,8 @@ export const MobileSettingsScreen: React.FC = () => {
<div className="space-y-4 pb-20 p-4">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-slate-800">Settings</h1>
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
<h1 className="text-2xl font-bold text-slate-800 dark:text-avus">Settings</h1>
<p className="text-slate-500 dark:text-titanio mt-2">Manage your account and preferences</p>
</div>
{/* Pending Deletion Banner */}
@@ -195,7 +195,7 @@ export const MobileSettingsScreen: React.FC = () => {
<GlassCard padding="md">
<div>
<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 && (
<button
onClick={handleEditProfile}
@@ -214,21 +214,21 @@ export const MobileSettingsScreen: React.FC = () => {
) : isEditingProfile ? (
<div className="space-y-4">
<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
</label>
<input
type="email"
value={profile?.email || ''}
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' }}
/>
<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>
<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
</label>
<input
@@ -236,13 +236,13 @@ export const MobileSettingsScreen: React.FC = () => {
value={editedDisplayName}
onChange={(e) => setEditedDisplayName(e.target.value)}
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' }}
/>
</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
</label>
<input
@@ -250,17 +250,17 @@ export const MobileSettingsScreen: React.FC = () => {
value={editedNotificationEmail}
onChange={(e) => setEditedNotificationEmail(e.target.value)}
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' }}
/>
<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 className="flex space-x-3 pt-2">
<button
onClick={handleCancelEdit}
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' }}
>
Cancel
@@ -294,21 +294,21 @@ export const MobileSettingsScreen: React.FC = () => {
</div>
)}
<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'}
</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 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>
<p className="text-xs font-medium text-slate-500 uppercase">Display Name</p>
<p className="text-sm text-slate-800">{profile?.displayName || 'Not set'}</p>
<p className="text-xs font-medium text-slate-500 dark:text-canna uppercase">Display Name</p>
<p className="text-sm text-slate-800 dark:text-avus">{profile?.displayName || 'Not set'}</p>
</div>
<div>
<p className="text-xs font-medium text-slate-500 uppercase">Notification Email</p>
<p className="text-sm text-slate-800">{profile?.notificationEmail || 'Using primary 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 dark:text-avus">{profile?.notificationEmail || 'Using primary email'}</p>
</div>
</div>
</div>
@@ -319,7 +319,7 @@ export const MobileSettingsScreen: React.FC = () => {
{/* Notifications Section */}
<GlassCard padding="md">
<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">
<ToggleSwitch
enabled={settings.notifications.email}
@@ -355,7 +355,7 @@ export const MobileSettingsScreen: React.FC = () => {
{/* Appearance & Units Section */}
<GlassCard padding="md">
<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">
<ToggleSwitch
enabled={settings.darkMode}
@@ -366,8 +366,8 @@ export const MobileSettingsScreen: React.FC = () => {
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-800">Unit System</p>
<p className="text-sm text-slate-500">
<p className="font-medium text-slate-800 dark:text-avus">Unit System</p>
<p className="text-sm text-slate-500 dark:text-titanio">
Currently using {settings.unitSystem === 'imperial' ? 'Miles, Gallons, MPG, USD' : 'Km, Liters, L/100km, EUR'}
</p>
</div>
@@ -385,7 +385,7 @@ export const MobileSettingsScreen: React.FC = () => {
{/* Data Management Section */}
<GlassCard padding="md">
<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">
<button
onClick={() => setShowDataExport(true)}
@@ -393,7 +393,7 @@ export const MobileSettingsScreen: React.FC = () => {
>
Export My Data
</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
</p>
</div>
@@ -421,7 +421,7 @@ export const MobileSettingsScreen: React.FC = () => {
{!adminLoading && isAdmin && (
<GlassCard padding="md">
<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">
<button
onClick={() => navigateToScreen('AdminUsers')}
@@ -463,11 +463,11 @@ export const MobileSettingsScreen: React.FC = () => {
{/* Account Actions Section */}
<GlassCard padding="md">
<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">
<button
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
</button>
@@ -487,7 +487,7 @@ export const MobileSettingsScreen: React.FC = () => {
onClose={() => setShowDataExport(false)}
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.
</p>
<div className="flex space-x-3">

View File

@@ -13,4 +13,25 @@ body {
* {
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,
Typography,
Alert,
Collapse,
} from '@mui/material';
import {
Search,
@@ -35,6 +36,8 @@ import {
FileDownload,
FileUpload,
Clear,
ExpandMore,
ExpandLess,
} from '@mui/icons-material';
import toast from 'react-hot-toast';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
@@ -52,6 +55,7 @@ import {
import {
CatalogSearchResult,
ImportPreviewResult,
ImportApplyResult,
} from '../../features/admin/types/admin.types';
const PAGE_SIZE_OPTIONS = [25, 50, 100];
@@ -76,6 +80,8 @@ export const AdminCatalogPage: React.FC = () => {
// Import state
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importPreview, setImportPreview] = useState<ImportPreviewResult | null>(null);
const [importResult, setImportResult] = useState<ImportApplyResult | null>(null);
const [errorsExpanded, setErrorsExpanded] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Hooks
@@ -217,15 +223,38 @@ export const AdminCatalogPage: React.FC = () => {
if (!importPreview?.previewId) return;
try {
await importApplyMutation.mutateAsync(importPreview.previewId);
setImportDialogOpen(false);
setImportPreview(null);
const result = await importApplyMutation.mutateAsync(importPreview.previewId);
setImportResult(result);
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();
} catch (error) {
// Error is handled by mutation
// Error is handled by mutation's onError
}
}, [importPreview, importApplyMutation, refetch]);
const handleImportDialogClose = useCallback(() => {
if (importApplyMutation.isPending) return;
setImportDialogOpen(false);
setImportPreview(null);
setImportResult(null);
setErrorsExpanded(false);
}, [importApplyMutation.isPending]);
// Export handler
const handleExport = useCallback(() => {
exportMutation.mutate();
@@ -506,18 +535,20 @@ export const AdminCatalogPage: React.FC = () => {
</DialogActions>
</Dialog>
{/* Import Preview Dialog */}
{/* Import Preview/Results Dialog */}
<Dialog
open={importDialogOpen}
onClose={() => !importApplyMutation.isPending && setImportDialogOpen(false)}
onClose={handleImportDialogClose}
maxWidth="md"
fullWidth
>
<DialogTitle>Import Preview</DialogTitle>
<DialogTitle>
{importResult ? 'Import Results' : 'Import Preview'}
</DialogTitle>
<DialogContent>
{importPreview && (
{/* Preview Mode */}
{importPreview && !importResult && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
{/* Summary */}
<Box sx={{ display: 'flex', gap: 3 }}>
<Typography>
<strong>To Create:</strong> {importPreview.toCreate.length}
@@ -527,7 +558,6 @@ export const AdminCatalogPage: React.FC = () => {
</Typography>
</Box>
{/* Errors */}
{importPreview.errors.length > 0 && (
<Alert severity="error">
<Typography variant="subtitle2" gutterBottom>
@@ -546,7 +576,6 @@ export const AdminCatalogPage: React.FC = () => {
</Alert>
)}
{/* Valid status */}
{importPreview.valid ? (
<Alert severity="success">
The import file is valid and ready to be applied.
@@ -558,23 +587,86 @@ export const AdminCatalogPage: React.FC = () => {
)}
</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>
<DialogActions>
<Button
onClick={() => setImportDialogOpen(false)}
onClick={handleImportDialogClose}
disabled={importApplyMutation.isPending}
sx={{ textTransform: 'none' }}
>
Cancel
</Button>
<Button
onClick={handleImportConfirm}
disabled={!importPreview?.valid || importApplyMutation.isPending}
variant="contained"
sx={{ textTransform: 'none' }}
>
{importApplyMutation.isPending ? <CircularProgress size={20} /> : 'Apply Import'}
{importResult ? 'Close' : 'Cancel'}
</Button>
{!importResult && (
<Button
onClick={handleImportConfirm}
disabled={!importPreview?.valid || importApplyMutation.isPending}
variant="contained"
sx={{ textTransform: 'none' }}
>
{importApplyMutation.isPending ? <CircularProgress size={20} /> : 'Apply Import'}
</Button>
)}
</DialogActions>
</Dialog>
</Box>

View File

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

View File

@@ -28,7 +28,7 @@ export const GlassCard: React.FC<GlassCardProps> = ({
return (
<div
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],
onClick && 'cursor-pointer hover:shadow-xl hover:-translate-y-0.5 transition',
className

View File

@@ -15,7 +15,7 @@ export const MobileContainer: React.FC<MobileContainerProps> = ({
}) => {
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 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}
</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",
active
? "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
)}
>

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