Compare commits

..

3 commits

199 changed files with 2072 additions and 92790 deletions

33
.env Normal file
View file

@ -0,0 +1,33 @@
# === AI CONFIGURATION ===
# OpenRouter API key for text generation (DeepSeek)
AI_API_KEY=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
# DeepSeek model for text/keyword generation
AI_MODEL_NAME=deepseek/deepseek-chat-v3-0324:free
# OpenRouter API URL
AI_API_URL=https://openrouter.ai/api/v1/chat/completions
# === GROK VISION API CONFIGURATION ===
# Grok API key for vision analysis (add your Grok API key here)
GROK_API_KEY=sk-voidai-vVU2HHiq1txTNmXdZOP98LzARmi4HsptTixMqFSX4yBbw8ogvmKlJEPeKrH1hwEd6j6AnED9LsR6ztPtRMT7UzeLOyxQkasbwKow
# Grok-2-Vision model
GROK_VISION_MODEL=grok-2-vision-1212
# Grok API URL
GROK_API_URL=https://api.voidai.app/v1/
# Optional: AI task-specific configuration
AI_TASK=keyword_generation
AI_RENAME_STRATEGY=descriptive # Options: 'timestamped', 'uuid', 'descriptive'
# === GENERAL SETTINGS ===
# Environment type
NODE_ENV=development
# Optional: Logging or debugging
ENABLE_LOGGING=true

View file

@ -1,240 +0,0 @@
# AI Bulk Image Renamer - Environment Variables Template
# Copy this file to .env and update with your actual values
# =============================================================================
# APPLICATION CONFIGURATION
# =============================================================================
# Environment (development, staging, production)
NODE_ENV=development
# Application
APP_NAME="AI Bulk Image Renamer"
APP_VERSION=1.0.0
APP_URL=http://localhost:3000
APP_PORT=3000
API_PORT=3001
# Application Security
APP_SECRET=your_super_secret_key_change_this_in_production
JWT_SECRET=your_jwt_secret_key_minimum_32_characters
JWT_EXPIRES_IN=7d
JWT_REFRESH_EXPIRES_IN=30d
# Session Configuration
SESSION_SECRET=your_session_secret_key
SESSION_MAX_AGE=86400000
# CORS Settings
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
CORS_CREDENTIALS=true
# =============================================================================
# DATABASE CONFIGURATION
# =============================================================================
# PostgreSQL Database
DATABASE_URL=postgresql://postgres:dev_password_123@localhost:5432/ai_image_renamer_dev
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=ai_image_renamer_dev
POSTGRES_USER=postgres
POSTGRES_PASSWORD=dev_password_123
# Database Pool Settings
DB_POOL_MIN=2
DB_POOL_MAX=10
DB_POOL_IDLE_TIMEOUT=30000
DB_POOL_ACQUIRE_TIMEOUT=60000
# =============================================================================
# REDIS CONFIGURATION
# =============================================================================
# Redis Cache & Queues
REDIS_URL=redis://localhost:6379
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Redis Queue Settings
REDIS_QUEUE_DB=1
REDIS_SESSION_DB=2
REDIS_CACHE_DB=3
# Cache Settings
CACHE_TTL=3600
CACHE_MAX_ITEMS=1000
# =============================================================================
# OBJECT STORAGE (MinIO/S3)
# =============================================================================
# MinIO Configuration
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minio_dev_user
MINIO_SECRET_KEY=minio_dev_password_123
MINIO_USE_SSL=false
MINIO_PORT=9000
# S3 Buckets
S3_BUCKET_IMAGES=images
S3_BUCKET_PROCESSED=processed
S3_BUCKET_TEMP=temp
S3_REGION=us-east-1
# File Upload Settings
MAX_FILE_SIZE=50MB
ALLOWED_IMAGE_TYPES=jpg,jpeg,png,webp,gif,bmp,tiff
MAX_FILES_PER_BATCH=100
UPLOAD_TIMEOUT=300000
# =============================================================================
# AI & PROCESSING CONFIGURATION
# =============================================================================
# OpenAI Configuration
OPENAI_API_KEY=sk-your_openai_api_key_here
OPENAI_MODEL=gpt-4
OPENAI_MAX_TOKENS=150
OPENAI_TEMPERATURE=0.3
# Alternative AI Providers (optional)
ANTHROPIC_API_KEY=
GOOGLE_AI_API_KEY=
AZURE_OPENAI_ENDPOINT=
AZURE_OPENAI_API_KEY=
# Image Processing
IMAGE_QUALITY=85
IMAGE_MAX_WIDTH=2048
IMAGE_MAX_HEIGHT=2048
THUMBNAIL_SIZE=300
WATERMARK_ENABLED=false
# Processing Limits
MAX_CONCURRENT_JOBS=5
JOB_TIMEOUT=600000
RETRY_ATTEMPTS=3
RETRY_DELAY=5000
# =============================================================================
# SECURITY & ANTIVIRUS
# =============================================================================
# ClamAV Antivirus
CLAMAV_HOST=localhost
CLAMAV_PORT=3310
CLAMAV_TIMEOUT=30000
VIRUS_SCAN_ENABLED=true
# Rate Limiting
RATE_LIMIT_WINDOW=900000
RATE_LIMIT_MAX_REQUESTS=100
RATE_LIMIT_SKIP_SUCCESSFUL=true
# Security Headers
SECURITY_HSTS_MAX_AGE=31536000
SECURITY_CONTENT_TYPE_NOSNIFF=true
SECURITY_FRAME_OPTIONS=DENY
SECURITY_XSS_PROTECTION=true
# =============================================================================
# EMAIL CONFIGURATION
# =============================================================================
# SMTP Settings
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
# Email Settings
EMAIL_FROM="AI Image Renamer <noreply@example.com>"
EMAIL_REPLY_TO=support@example.com
ADMIN_EMAIL=admin@example.com
# Email Templates
EMAIL_VERIFICATION_ENABLED=true
EMAIL_NOTIFICATIONS_ENABLED=true
# =============================================================================
# LOGGING & MONITORING
# =============================================================================
# Logging Configuration
LOG_LEVEL=info
LOG_FORMAT=combined
LOG_FILE_ENABLED=true
LOG_FILE_PATH=./logs/app.log
LOG_MAX_SIZE=10MB
LOG_MAX_FILES=5
# Monitoring
HEALTH_CHECK_ENABLED=true
METRICS_ENABLED=true
METRICS_PORT=9090
# Sentry Error Tracking (optional)
SENTRY_DSN=
SENTRY_ENVIRONMENT=development
SENTRY_RELEASE=
# =============================================================================
# BUSINESS LOGIC CONFIGURATION
# =============================================================================
# User Limits
FREE_TIER_MONTHLY_LIMIT=100
PREMIUM_TIER_MONTHLY_LIMIT=10000
MAX_API_CALLS_PER_MINUTE=10
# SEO Settings
SEO_MIN_FILENAME_LENGTH=10
SEO_MAX_FILENAME_LENGTH=100
SEO_INCLUDE_ALT_TEXT=true
SEO_INCLUDE_KEYWORDS=true
# Subscription & Billing (Stripe)
STRIPE_PUBLIC_KEY=pk_test_your_stripe_public_key
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
# =============================================================================
# EXTERNAL SERVICES
# =============================================================================
# Google Analytics
GA_TRACKING_ID=
GA_MEASUREMENT_ID=
# Social Login (optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# CDN Configuration
CDN_URL=
CDN_ENABLED=false
# =============================================================================
# DEVELOPMENT & TESTING
# =============================================================================
# Development Settings
ENABLE_CORS=true
ENABLE_SWAGGER=true
ENABLE_PLAYGROUND=true
ENABLE_DEBUG_LOGS=true
# Test Database (for running tests)
TEST_DATABASE_URL=postgresql://postgres:test_password@localhost:5432/ai_image_renamer_test
# Feature Flags
FEATURE_BATCH_PROCESSING=true
FEATURE_AI_SUGGESTIONS=true
FEATURE_BULK_OPERATIONS=true
FEATURE_ANALYTICS=false

View file

@ -1,204 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
project: ['./tsconfig.json', './packages/*/tsconfig.json'],
tsconfigRootDir: __dirname,
},
plugins: [
'@typescript-eslint',
'import',
'node',
'prettier'
],
extends: [
'eslint:recommended',
'@typescript-eslint/recommended',
'@typescript-eslint/recommended-requiring-type-checking',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:node/recommended',
'prettier'
],
env: {
node: true,
es2022: true,
jest: true
},
settings: {
'import/resolver': {
typescript: {
alwaysTryTypes: true,
project: ['./tsconfig.json', './packages/*/tsconfig.json']
},
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
}
},
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
}
},
rules: {
// TypeScript specific rules
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/prefer-const': 'error',
'@typescript-eslint/no-var-requires': 'error',
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-empty-function': 'warn',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/consistent-type-imports': ['error', {
prefer: 'type-imports',
disallowTypeAnnotations: false
}],
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/prefer-optional-chain': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/no-misused-promises': 'error',
// Import/Export rules
'import/order': ['error', {
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index'
],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true
}
}],
'import/no-unresolved': 'error',
'import/no-cycle': 'error',
'import/no-self-import': 'error',
'import/no-useless-path-segments': 'error',
'import/prefer-default-export': 'off',
'import/no-default-export': 'off',
'import/no-duplicates': 'error',
// Node.js specific rules
'node/no-missing-import': 'off', // Handled by TypeScript
'node/no-unsupported-features/es-syntax': 'off', // We use Babel/TypeScript
'node/no-unpublished-import': 'off',
'node/no-extraneous-import': 'off', // Handled by import plugin
'node/prefer-global/process': 'error',
'node/prefer-global/console': 'error',
'node/prefer-global/buffer': 'error',
'node/prefer-global/url': 'error',
// General JavaScript/TypeScript rules
'no-console': 'warn',
'no-debugger': 'error',
'no-alert': 'error',
'no-var': 'error',
'prefer-const': 'error',
'prefer-template': 'error',
'prefer-arrow-callback': 'error',
'arrow-spacing': 'error',
'object-shorthand': 'error',
'prefer-destructuring': ['error', {
array: false,
object: true
}],
'no-duplicate-imports': 'error',
'no-useless-constructor': 'error',
'no-useless-rename': 'error',
'no-useless-return': 'error',
'no-unreachable': 'error',
'no-trailing-spaces': 'error',
'eol-last': 'error',
'comma-dangle': ['error', 'always-multiline'],
'semi': ['error', 'always'],
'quotes': ['error', 'single', { avoidEscape: true }],
// Security rules
'no-eval': 'error',
'no-implied-eval': 'error',
'no-new-func': 'error',
'no-script-url': 'error',
// Performance rules
'no-async-promise-executor': 'error',
'no-await-in-loop': 'warn',
'no-promise-executor-return': 'error',
// Prettier integration
'prettier/prettier': 'error'
},
overrides: [
// Configuration files
{
files: [
'*.config.js',
'*.config.ts',
'.eslintrc.js',
'jest.config.js',
'vite.config.ts'
],
rules: {
'node/no-unpublished-require': 'off',
'@typescript-eslint/no-var-requires': 'off'
}
},
// Test files
{
files: ['**/*.test.ts', '**/*.spec.ts', '**/__tests__/**/*'],
env: {
jest: true
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'no-console': 'off'
}
},
// Frontend specific rules
{
files: ['packages/frontend/**/*'],
env: {
browser: true,
node: false
},
rules: {
'node/prefer-global/process': 'off'
}
},
// API and Worker specific rules
{
files: ['packages/api/**/*', 'packages/worker/**/*'],
env: {
node: true,
browser: false
},
rules: {
'no-console': 'off' // Allow console in server code
}
}
],
ignorePatterns: [
'node_modules/',
'dist/',
'build/',
'coverage/',
'*.min.js',
'*.bundle.js'
]
};

View file

@ -1,397 +0,0 @@
name: CI Pipeline
on:
push:
branches: [ main, develop, 'feature/*', 'hotfix/*' ]
pull_request:
branches: [ main, develop ]
workflow_dispatch:
env:
NODE_VERSION: '18'
PNPM_VERSION: '8.15.0'
jobs:
# Install dependencies and cache
setup:
name: Setup Dependencies
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-keys.outputs.node-modules }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Generate cache keys
id: cache-keys
run: |
echo "node-modules=${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ env.STORE_PATH }}
key: ${{ steps.cache-keys.outputs.node-modules }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache node_modules
uses: actions/cache@v3
id: cache-node-modules
with:
path: |
node_modules
packages/*/node_modules
key: ${{ steps.cache-keys.outputs.node-modules }}-modules
restore-keys: |
${{ runner.os }}-pnpm-modules-
# Linting and formatting
lint:
name: Lint & Format Check
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Restore dependencies
uses: actions/cache@v3
with:
path: |
node_modules
packages/*/node_modules
key: ${{ needs.setup.outputs.cache-key }}-modules
- name: Run ESLint
run: pnpm lint
- name: Check Prettier formatting
run: pnpm format:check
- name: TypeScript type check
run: pnpm typecheck
# Unit tests
test:
name: Unit Tests
runs-on: ubuntu-latest
needs: setup
strategy:
matrix:
package: [api, worker, frontend, shared]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Restore dependencies
uses: actions/cache@v3
with:
path: |
node_modules
packages/*/node_modules
key: ${{ needs.setup.outputs.cache-key }}-modules
- name: Run tests for ${{ matrix.package }}
run: pnpm --filter @ai-renamer/${{ matrix.package }} test
- name: Generate coverage report
run: pnpm --filter @ai-renamer/${{ matrix.package }} test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./packages/${{ matrix.package }}/coverage/lcov.info
flags: ${{ matrix.package }}
name: ${{ matrix.package }}-coverage
fail_ci_if_error: false
# Integration tests
integration-test:
name: Integration Tests
runs-on: ubuntu-latest
needs: setup
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test_password
POSTGRES_DB: ai_image_renamer_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
minio:
image: minio/minio:latest
env:
MINIO_ROOT_USER: test_user
MINIO_ROOT_PASSWORD: test_password
options: >-
--health-cmd "curl -f http://localhost:9000/minio/health/live"
--health-interval 30s
--health-timeout 20s
--health-retries 3
ports:
- 9000:9000
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Restore dependencies
uses: actions/cache@v3
with:
path: |
node_modules
packages/*/node_modules
key: ${{ needs.setup.outputs.cache-key }}-modules
- name: Setup test environment
run: |
cp .env.example .env.test
echo "DATABASE_URL=postgresql://postgres:test_password@localhost:5432/ai_image_renamer_test" >> .env.test
echo "REDIS_URL=redis://localhost:6379" >> .env.test
echo "MINIO_ENDPOINT=localhost:9000" >> .env.test
echo "MINIO_ACCESS_KEY=test_user" >> .env.test
echo "MINIO_SECRET_KEY=test_password" >> .env.test
- name: Run database migrations
run: pnpm --filter @ai-renamer/api db:migrate
- name: Run integration tests
run: pnpm test:integration
env:
NODE_ENV: test
# Build application
build:
name: Build Application
runs-on: ubuntu-latest
needs: [setup, lint, test]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Restore dependencies
uses: actions/cache@v3
with:
path: |
node_modules
packages/*/node_modules
key: ${{ needs.setup.outputs.cache-key }}-modules
- name: Build all packages
run: pnpm build
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: build-artifacts
path: |
packages/*/dist
packages/*/build
retention-days: 7
# Docker build and test
docker:
name: Docker Build & Test
runs-on: ubuntu-latest
needs: [setup, lint, test]
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
target: production
tags: ai-bulk-image-renamer:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=docker,dest=/tmp/image.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@v3
with:
name: docker-image
path: /tmp/image.tar
retention-days: 1
- name: Test Docker image
run: |
docker load < /tmp/image.tar
docker run --rm --name test-container -d \
-e NODE_ENV=test \
ai-bulk-image-renamer:${{ github.sha }}
sleep 10
docker logs test-container
docker stop test-container
# Security scanning
security:
name: Security Scan
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Restore dependencies
uses: actions/cache@v3
with:
path: |
node_modules
packages/*/node_modules
key: ${{ needs.setup.outputs.cache-key }}-modules
- name: Run npm audit
run: pnpm audit --audit-level moderate
continue-on-error: true
- name: Run Snyk security scan
uses: snyk/actions/node@master
continue-on-error: true
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=medium
# Dependency updates check
dependency-updates:
name: Check Dependency Updates
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Check for outdated dependencies
run: pnpm outdated
- name: Create dependency update issue
if: failure()
uses: actions/github-script@v6
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Dependency Updates Available',
body: 'Automated check found outdated dependencies. Please review and update.',
labels: ['dependencies', 'maintenance']
})
# Deployment readiness check
deploy-check:
name: Deployment Readiness
runs-on: ubuntu-latest
needs: [build, docker, security, integration-test]
if: github.ref == 'refs/heads/main'
steps:
- name: Deployment ready
run: |
echo "✅ All checks passed - ready for deployment"
echo "Build artifacts and Docker image are available"
echo "Security scans completed"
echo "Integration tests passed"

366
.gitignore vendored
View file

@ -1,366 +0,0 @@
# =============================================================================
# Node.js & JavaScript
# =============================================================================
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage
.grunt
# Bower dependency directory
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons
build/Release
# Dependency directories
jspm_packages/
# Snowpack dependency directory
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# =============================================================================
# Build Outputs
# =============================================================================
# Distribution directories
dist/
build/
out/
.output/
.vercel/
.netlify/
# Vite build outputs
.vite/
# Next.js build output
.next/
# Nuxt.js build / generate output
.nuxt/
# Gatsby files
.cache/
public/
# Webpack bundles
*.bundle.js
*.bundle.js.map
# =============================================================================
# Environment & Configuration
# =============================================================================
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.*.local
# Docker environment files
.env.docker
docker-compose.override.yml
# Configuration files with secrets
config.json
secrets.json
credentials.json
# =============================================================================
# Logs
# =============================================================================
# Log files
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Application logs
app.log
error.log
access.log
combined.log
# PM2 logs
.pm2/
# =============================================================================
# Database & Storage
# =============================================================================
# SQLite databases
*.sqlite
*.sqlite3
*.db
# Database dumps
*.sql
*.dump
# Redis dumps
dump.rdb
# =============================================================================
# Cloud & Deployment
# =============================================================================
# AWS
.aws/
aws-exports.js
# Serverless directories
.serverless/
# Terraform
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
# Pulumi
Pulumi.*.yaml
# =============================================================================
# Development Tools
# =============================================================================
# IDE/Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# Temporary files
*.tmp
*.temp
temp/
tmp/
# =============================================================================
# Testing
# =============================================================================
# Test outputs
test-results/
playwright-report/
test-report/
# Coverage reports
coverage/
.coverage
htmlcov/
# Jest
jest-coverage/
# =============================================================================
# Security & Certificates
# =============================================================================
# SSL certificates
*.pem
*.key
*.crt
*.cert
*.p12
*.pfx
# Private keys
id_rsa
id_ed25519
*.priv
# GPG keys
*.gpg
*.asc
# =============================================================================
# Application Specific
# =============================================================================
# Uploaded files
uploads/
user-uploads/
temp-uploads/
# Processed images
processed/
thumbnails/
# Cache directories
.cache/
cache/
.temp/
# Session storage
sessions/
# MinIO/S3 local storage
minio-data/
s3-local/
# ClamAV database
clamav-db/
# =============================================================================
# Monitoring & Analytics
# =============================================================================
# Sentry
.sentryclirc
# New Relic
newrelic_agent.log
# Application monitoring
apm-agent-nodejs.log
# =============================================================================
# Package Managers
# =============================================================================
# pnpm
.pnpm-debug.log*
.pnpm-store/
# Yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnp.*
# =============================================================================
# Miscellaneous
# =============================================================================
# Backup files
*.bak
*.backup
*.old
*.orig
# Archive files
*.zip
*.tar.gz
*.rar
*.7z
# Large media files (development)
*.mov
*.mp4
*.avi
*.mkv
*.webm
# Documentation builds
docs/build/
site/
# Storybook build outputs
storybook-static/
# Chromatic
build-storybook.log
# =============================================================================
# Local Development
# =============================================================================
# Local configuration
.local
.development
dev.json
# Database seeds (if containing sensitive data)
seeds/local/
# Local scripts
scripts/local/
# Development certificates
dev-certs/
# Hot reload
.hot-reload
# =============================================================================
# CI/CD
# =============================================================================
# Build artifacts from CI
artifacts/
reports/
# Deployment scripts with secrets
deploy-secrets.sh
deploy.env

View file

@ -1,86 +0,0 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"quoteProps": "as-needed",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": true,
"proseWrap": "preserve",
"requirePragma": false,
"overrides": [
{
"files": "*.json",
"options": {
"printWidth": 120,
"tabWidth": 2
}
},
{
"files": "*.md",
"options": {
"printWidth": 100,
"proseWrap": "always",
"tabWidth": 2
}
},
{
"files": "*.yml",
"options": {
"tabWidth": 2,
"singleQuote": false
}
},
{
"files": "*.yaml",
"options": {
"tabWidth": 2,
"singleQuote": false
}
},
{
"files": "*.html",
"options": {
"printWidth": 120,
"tabWidth": 2,
"htmlWhitespaceSensitivity": "ignore"
}
},
{
"files": "*.css",
"options": {
"printWidth": 120,
"tabWidth": 2
}
},
{
"files": "*.scss",
"options": {
"printWidth": 120,
"tabWidth": 2
}
},
{
"files": "*.tsx",
"options": {
"jsxSingleQuote": true,
"bracketSameLine": false
}
},
{
"files": "*.jsx",
"options": {
"jsxSingleQuote": true,
"bracketSameLine": false
}
}
]
}

125
CLAUDE.md
View file

@ -1,125 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is the **AI Bulk Image Renamer** SaaS - a web application that allows users to rename multiple images in batches using AI-generated keywords and computer vision tags. The goal is to create SEO-friendly, filesystem-safe, and semantically descriptive filenames.
### MVP Requirements (From README.md:22-31)
- Single landing page with upload functionality
- User-supplied keywords for filename generation
- "Enhance with AI" button to expand keyword lists
- Image thumbnails display after upload
- Generated filenames shown beneath corresponding images
- Download as ZIP functionality for renamed images
## Architecture & Tech Stack
Based on the development plans, the intended architecture is:
### Stack (From plan-for-devs.md:6-13)
- **Monorepo**: pnpm workspaces
- **Language**: TypeScript everywhere (Next.js + tRPC or Nest.js API / BullMQ worker)
- **Database**: PostgreSQL 15 via Prisma
- **Queues**: Redis + BullMQ for background jobs
- **Containers**: Docker dev-container with Docker Compose
### Core Components
- **Frontend**: Next.js with drag-and-drop upload, progress tracking, review table
- **Backend API**: Authentication (Google OAuth), quota management, batch processing
- **Worker Service**: Image processing, virus scanning (ClamAV), AI vision analysis
- **Object Storage**: MinIO (S3-compatible) for image storage
### Database Schema (From plan-for-devs.md:39-42)
- `users` table with Google OAuth integration
- `batches` table for upload sessions
- `images` table for individual image processing
## Key Features & Requirements
### Quota System
- **Basic Plan**: 50 images/month (free)
- **Pro Plan**: 500 images/month
- **Max Plan**: 1,000 images/month
### Processing Pipeline
1. File upload with SHA-256 deduplication
2. Virus scanning with ClamAV
3. Google Cloud Vision API for image labeling (>0.40 confidence)
4. Filename generation algorithm
5. Real-time progress via WebSockets
6. Review table with inline editing
7. ZIP download with preserved EXIF data
## Development Workflow
### Branch Strategy (From plan-for-devs.md:18-26)
- **Main branch**: `main` (always deployable)
- **Feature branches**: `feature/*`, `bugfix/*`
- **Release branches**: `release/*` (optional)
- **Hotfix branches**: `hotfix/*`
### Team Structure (From plan-for-devs.md:17-25)
- **Dev A**: Backend/API (Auth, quota, DB migrations)
- **Dev B**: Worker & Vision (Queue, ClamAV, Vision processing)
- **Dev C**: Frontend (Dashboard, drag-and-drop, review table)
## Security & Compliance
### Requirements
- Google OAuth 2.0 with email scope only
- GPG/SSH signed commits required
- Branch protection on `main` with 2 reviewer approvals
- ClamAV virus scanning before processing
- Rate limiting on all API endpoints
- Secrets stored in Forgejo encrypted vault
### Data Handling
- Only hashed emails stored
- EXIF data preservation
- Secure object storage paths: `/{batchUuid}/{filename}`
## Development Environment
### Local Setup (From plan-for-devs.md:32-37)
```yaml
# docker-compose.dev.yml services needed:
- postgres
- redis
- maildev (for testing)
- minio (S3-compatible object store)
- clamav
```
### CI/CD Pipeline (From plan-for-devs.md:46-52)
- ESLint + Prettier + Vitest/Jest + Cypress
- Forgejo Actions with Docker runner
- Multi-stage Dockerfile (≤300MB final image)
- Status checks required for merge
## API Endpoints
### Core Endpoints (From plan-for-devs.md:49-52)
- `/api/batch` - Create new batch, accept multipart form
- `/api/batch/{id}/status` - Get processing status
- `/api/batch/{id}/zip` - Download renamed images
- WebSocket connection for real-time progress updates
## Performance & Monitoring
### Targets
- Lighthouse scores ≥90
- OpenTelemetry trace IDs
- Prometheus histograms
- Kubernetes liveness & readiness probes
## Important Files
- `README.md` - Full product specification
- `plan-for-devs.md` - Development workflow and team structure
- `plan.md` - Detailed 7-week development backlog
## No Build Commands Available
This repository currently contains only planning documents. No package.json, requirements.txt, or other dependency files exist yet. The actual codebase implementation will follow the technical specifications outlined in the planning documents.

View file

@ -1,126 +0,0 @@
# Multi-stage Dockerfile for AI Bulk Image Renamer
# Target: Alpine Linux for minimal size (<300MB)
# Build stage
FROM node:18-alpine AS builder
# Install build dependencies
RUN apk add --no-cache \
python3 \
make \
g++ \
libc6-compat \
vips-dev
# Enable pnpm
RUN corepack enable pnpm
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml* ./
COPY packages/*/package.json ./packages/*/
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build all packages
RUN pnpm build
# Prune dev dependencies
RUN pnpm prune --prod
# Production stage
FROM node:18-alpine AS production
# Install runtime dependencies
RUN apk add --no-cache \
vips \
curl \
tini \
dumb-init \
&& addgroup -g 1001 -S nodejs \
&& adduser -S nodeuser -u 1001
# Enable pnpm
RUN corepack enable pnpm
# Set working directory
WORKDIR /app
# Copy package files and node_modules from builder
COPY --from=builder --chown=nodeuser:nodejs /app/package.json ./
COPY --from=builder --chown=nodeuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodeuser:nodejs /app/packages ./packages
# Create necessary directories
RUN mkdir -p /app/logs /app/uploads /app/temp \
&& chown -R nodeuser:nodejs /app
# Switch to non-root user
USER nodeuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Expose port
EXPOSE 3000
# Use tini as PID 1
ENTRYPOINT ["/sbin/tini", "--"]
# Default command (can be overridden)
CMD ["pnpm", "start"]
# Worker stage (for background processing)
FROM production AS worker
# Override default command for worker
CMD ["pnpm", "start:worker"]
# Development stage
FROM node:18-alpine AS development
# Install development dependencies
RUN apk add --no-cache \
python3 \
make \
g++ \
libc6-compat \
vips-dev \
git \
curl
# Enable pnpm
RUN corepack enable pnpm
# Create user
RUN addgroup -g 1001 -S nodejs \
&& adduser -S nodeuser -u 1001
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml* ./
COPY packages/*/package.json ./packages/*/
# Install all dependencies (including dev)
RUN pnpm install --frozen-lockfile
# Create necessary directories
RUN mkdir -p /app/logs /app/uploads /app/temp \
&& chown -R nodeuser:nodejs /app
# Switch to non-root user
USER nodeuser
# Expose port
EXPOSE 3000
# Start development server
CMD ["pnpm", "dev"]

75
GROK_SETUP.md Normal file
View file

@ -0,0 +1,75 @@
# Grok-2-Vision Integration Setup
## How to Configure Your Grok API Key
The SEO Image Renamer now supports Grok-2-Vision for enhanced image analysis. Follow these steps to set up your API key:
### Step 1: Get Your Grok API Key
1. Visit [https://console.x.ai/](https://console.x.ai/)
2. Sign up or log in to your account
3. Navigate to API Keys section
4. Create a new API key for your project
5. Copy the generated API key
### Step 2: Update the .env File
1. Open the `.env` file in your project directory
2. Find the line that says: `GROK_API_KEY=your_grok_api_key_here`
3. Replace `your_grok_api_key_here` with your actual Grok API key
4. Save the file
Example:
```env
GROK_API_KEY=gsk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567
```
### Step 3: Test the Integration
1. Open your SEO Image Renamer website
2. Upload some images
3. Add keywords and click "Generate SEO Keywords"
4. Check the browser console for the message: "Grok API Key configured: Yes"
## What's New with Grok-2-Vision
### Enhanced Image Analysis
- **Better Keyword Extraction**: Grok-2-Vision provides more accurate and contextual keywords from your images
- **SEO-Focused Analysis**: The AI is specifically prompted to identify keywords useful for SEO image naming
- **Improved Accuracy**: Better object and action recognition compared to previous vision models
### Technical Improvements
- **Dedicated Vision API**: Separate API endpoint optimized for vision tasks
- **Enhanced Prompts**: Specialized prompts for extracting SEO-relevant keywords
- **Better Error Handling**: Improved fallback mechanisms if vision analysis fails
- **Cleaner Responses**: Better parsing and filtering of AI responses
## Troubleshooting
### Common Issues
**Issue**: "Grok API Key configured: No" in console
**Solution**: Make sure you've replaced `sk-voidai-vVU2HHiq1txTNmXdZOP98LzARmi4HsptTixMqFSX4yBbw8ogvmKlJEPeKrH1hwEd6j6AnED9LsR6ztPtRMT7UzeLOyxQkasbwKow` with your actual API key in the .env file
**Issue**: Vision analysis fails or returns generic keywords
**Solution**:
1. Check that your API key is valid and has sufficient credits
2. Ensure your API key has access to Grok-2-Vision model
3. Check browser console for specific error messages
**Issue**: .env file not loading
**Solution**:
1. Make sure the .env file is in the same directory as index.html
2. Ensure your web server allows access to .env files (may need to serve from a local server)
3. For production, you may need to hardcode the API key in the JavaScript file
### API Limits
- Grok-2-Vision has rate limits and usage quotas
- If you hit limits, the system will fallback to generic keywords
- Monitor your usage in the X.AI console
## Security Note
Keep your API key secure and never commit it to public repositories. The .env file should be added to your .gitignore file.
## Support
If you encounter issues with the Grok-2-Vision integration, check:
1. X.AI API documentation: [https://docs.x.ai/](https://docs.x.ai/)
2. Your API key permissions and quotas
3. Browser console for detailed error messages

20
config.js Normal file
View file

@ -0,0 +1,20 @@
// Configuration file for SEO Image Renamer
// Update your API keys here
window.APP_CONFIG = {
// OpenRouter API for text generation (DeepSeek) - already configured
OPENROUTER_API_KEY: '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',
DEEPSEEK_MODEL: 'deepseek/deepseek-chat-v3-0324:free',
OPENROUTER_API_URL: 'https://openrouter.ai/api/v1/chat/completions',
// Grok-2-Vision API - ADD YOUR GROK API KEY HERE
GROK_API_KEY: 'sk-voidai-vVU2HHiq1txTNmXdZOP98LzARmi4HsptTixMqFSX4yBbw8ogvmKlJEPeKrH1hwEd6j6AnED9LsR6ztPtRMT7UzeLOyxQkasbwKow'
GROK_VISION_MODEL: 'grok-2-vision-1212',
GROK_API_URL: 'https://api.x.ai/v1/chat/completions'
};
// Instructions:
// 1. Get your Grok API key from https://console.x.ai/
// 2. Replace 'your_grok_api_key_here' above with your actual API key
// 3. Save this file
// 4. The website will automatically use your new API key

View file

@ -1,82 +0,0 @@
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
supportFile: 'cypress/support/e2e.ts',
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
videosFolder: 'cypress/videos',
screenshotsFolder: 'cypress/screenshots',
fixturesFolder: 'cypress/fixtures',
video: true,
screenshot: true,
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 10000,
requestTimeout: 10000,
responseTimeout: 10000,
pageLoadTimeout: 30000,
env: {
API_URL: 'http://localhost:3001',
TEST_USER_EMAIL: 'test@example.com',
TEST_USER_PASSWORD: 'TestPassword123!',
},
setupNodeEvents(on, config) {
// implement node event listeners here
on('task', {
// Custom tasks for database setup/teardown
clearDatabase() {
// Clear test database
return null;
},
seedDatabase() {
// Seed test database with fixtures
return null;
},
log(message) {
console.log(message);
return null;
},
});
// Code coverage plugin
// require('@cypress/code-coverage/task')(on, config);
return config;
},
},
component: {
devServer: {
framework: 'react',
bundler: 'webpack',
},
specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'cypress/support/component.ts',
},
// Global configuration
chromeWebSecurity: false,
modifyObstructiveCode: false,
experimentalStudio: true,
experimentalWebKitSupport: true,
// Retry configuration
retries: {
runMode: 2,
openMode: 0,
},
// Reporter configuration
reporter: 'mochawesome',
reporterOptions: {
reportDir: 'cypress/reports',
overwrite: false,
html: false,
json: true,
},
});

View file

@ -1,173 +0,0 @@
describe('Authentication Flow', () => {
beforeEach(() => {
cy.visit('/');
cy.clearLocalStorage();
});
describe('Google OAuth Sign In', () => {
it('should display sign in modal when accessing protected features', () => {
// Try to upload without signing in
cy.get('[data-cy=drop-area]').should('be.visible');
cy.get('[data-cy=file-input]').selectFile('cypress/fixtures/test-image.jpg', { force: true });
// Should show auth modal
cy.get('[data-cy=auth-modal]').should('be.visible');
cy.get('[data-cy=google-signin-btn]').should('be.visible');
});
it('should redirect to Google OAuth when clicking sign in', () => {
cy.get('[data-cy=signin-btn]').click();
cy.get('[data-cy=auth-modal]').should('be.visible');
// Mock Google OAuth response
cy.intercept('GET', '/api/auth/google', {
statusCode: 302,
headers: {
Location: 'https://accounts.google.com/oauth/authorize?...',
},
}).as('googleAuth');
cy.get('[data-cy=google-signin-btn]').click();
cy.wait('@googleAuth');
});
it('should handle successful authentication', () => {
// Mock successful auth callback
cy.intercept('GET', '/api/auth/google/callback*', {
statusCode: 200,
body: {
token: 'mock-jwt-token',
user: {
id: 'user-123',
email: 'test@example.com',
plan: 'BASIC',
quotaRemaining: 50,
},
},
}).as('authCallback');
// Mock user profile endpoint
cy.intercept('GET', '/api/auth/me', {
statusCode: 200,
body: {
id: 'user-123',
email: 'test@example.com',
plan: 'BASIC',
quotaRemaining: 50,
quotaLimit: 50,
},
}).as('userProfile');
// Simulate successful auth by setting token
cy.window().then((win) => {
win.localStorage.setItem('seo_auth_token', 'mock-jwt-token');
});
cy.reload();
// Should show user menu instead of sign in button
cy.get('[data-cy=user-menu]').should('be.visible');
cy.get('[data-cy=signin-menu]').should('not.exist');
});
});
describe('User Session', () => {
beforeEach(() => {
// Set up authenticated user
cy.window().then((win) => {
win.localStorage.setItem('seo_auth_token', 'mock-jwt-token');
});
cy.intercept('GET', '/api/auth/me', {
statusCode: 200,
body: {
id: 'user-123',
email: 'test@example.com',
plan: 'BASIC',
quotaRemaining: 30,
quotaLimit: 50,
},
}).as('userProfile');
});
it('should display user quota information', () => {
cy.visit('/');
cy.wait('@userProfile');
cy.get('[data-cy=quota-used]').should('contain', '20'); // 50 - 30
cy.get('[data-cy=quota-limit]').should('contain', '50');
cy.get('[data-cy=quota-fill]').should('have.css', 'width', '40%'); // 20/50 * 100
});
it('should handle logout', () => {
cy.intercept('POST', '/api/auth/logout', {
statusCode: 200,
body: { message: 'Logged out successfully' },
}).as('logout');
cy.visit('/');
cy.wait('@userProfile');
cy.get('[data-cy=user-menu]').click();
cy.get('[data-cy=logout-link]').click();
cy.wait('@logout');
// Should clear local storage and show sign in button
cy.window().its('localStorage').invoke('getItem', 'seo_auth_token').should('be.null');
cy.get('[data-cy=signin-menu]').should('be.visible');
});
it('should handle expired token', () => {
cy.intercept('GET', '/api/auth/me', {
statusCode: 401,
body: { message: 'Token expired' },
}).as('expiredToken');
cy.visit('/');
cy.wait('@expiredToken');
// Should clear token and show sign in
cy.window().its('localStorage').invoke('getItem', 'seo_auth_token').should('be.null');
cy.get('[data-cy=signin-menu]').should('be.visible');
});
});
describe('Quota Enforcement', () => {
it('should show upgrade modal when quota exceeded', () => {
cy.window().then((win) => {
win.localStorage.setItem('seo_auth_token', 'mock-jwt-token');
});
cy.intercept('GET', '/api/auth/me', {
statusCode: 200,
body: {
id: 'user-123',
email: 'test@example.com',
plan: 'BASIC',
quotaRemaining: 0,
quotaLimit: 50,
},
}).as('userProfileNoQuota');
cy.intercept('POST', '/api/batches', {
statusCode: 400,
body: { message: 'Quota exceeded' },
}).as('quotaExceeded');
cy.visit('/');
cy.wait('@userProfileNoQuota');
// Try to upload when quota is 0
cy.get('[data-cy=file-input]').selectFile('cypress/fixtures/test-image.jpg', { force: true });
cy.get('[data-cy=keyword-input]').type('test keywords');
cy.get('[data-cy=enhance-btn]').click();
cy.wait('@quotaExceeded');
// Should show upgrade modal
cy.get('[data-cy=subscription-modal]').should('be.visible');
cy.get('[data-cy=upgrade-btn]').should('have.length.greaterThan', 0);
});
});
});

View file

@ -1,135 +0,0 @@
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: ai-renamer-postgres-dev
environment:
POSTGRES_DB: ai_image_renamer_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: dev_password_123
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
ports:
- "5432:5432"
volumes:
- postgres_dev_data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d
networks:
- ai-renamer-dev
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d ai_image_renamer_dev"]
interval: 10s
timeout: 5s
retries: 5
# Redis Cache & Queue
redis:
image: redis:7-alpine
container_name: ai-renamer-redis-dev
ports:
- "6379:6379"
volumes:
- redis_dev_data:/data
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf
networks:
- ai-renamer-dev
restart: unless-stopped
command: redis-server /usr/local/etc/redis/redis.conf
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# MinIO Object Storage
minio:
image: minio/minio:latest
container_name: ai-renamer-minio-dev
environment:
MINIO_ROOT_USER: minio_dev_user
MINIO_ROOT_PASSWORD: minio_dev_password_123
MINIO_CONSOLE_ADDRESS: ":9001"
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_dev_data:/data
networks:
- ai-renamer-dev
restart: unless-stopped
command: server /data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# MinIO Client for bucket initialization
minio-client:
image: minio/mc:latest
container_name: ai-renamer-minio-client-dev
depends_on:
minio:
condition: service_healthy
networks:
- ai-renamer-dev
entrypoint: >
/bin/sh -c "
sleep 5;
/usr/bin/mc alias set minio http://minio:9000 minio_dev_user minio_dev_password_123;
/usr/bin/mc mb minio/images --ignore-existing;
/usr/bin/mc mb minio/processed --ignore-existing;
/usr/bin/mc mb minio/temp --ignore-existing;
/usr/bin/mc policy set public minio/images;
/usr/bin/mc policy set public minio/processed;
echo 'MinIO buckets created successfully';
"
# ClamAV Antivirus Scanner (commented out for ARM64 compatibility)
# clamav:
# image: clamav/clamav:latest
# container_name: ai-renamer-clamav-dev
# ports:
# - "3310:3310"
# volumes:
# - clamav_dev_data:/var/lib/clamav
# networks:
# - ai-renamer-dev
# restart: unless-stopped
# environment:
# CLAMAV_NO_FRESHCLAMD: "false"
# CLAMAV_NO_CLAMD: "false"
# healthcheck:
# test: ["CMD", "clamdscan", "--ping"]
# interval: 60s
# timeout: 30s
# retries: 3
# start_period: 300s
# Mailhog for email testing
mailhog:
image: mailhog/mailhog:latest
container_name: ai-renamer-mailhog-dev
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP
networks:
- ai-renamer-dev
restart: unless-stopped
volumes:
postgres_dev_data:
driver: local
redis_dev_data:
driver: local
minio_dev_data:
driver: local
clamav_dev_data:
driver: local
networks:
ai-renamer-dev:
driver: bridge
name: ai-renamer-dev-network

View file

@ -1,255 +0,0 @@
version: '3.8'
services:
# Main Application
app:
build:
context: .
dockerfile: Dockerfile
target: production
container_name: ai-renamer-app
environment:
NODE_ENV: production
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
REDIS_URL: redis://redis:6379
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
CLAMAV_HOST: clamav
CLAMAV_PORT: 3310
ports:
- "${APP_PORT:-3000}:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
networks:
- ai-renamer-prod
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
memory: 1G
cpus: '0.5'
reservations:
memory: 512M
cpus: '0.25'
# Worker Service
worker:
build:
context: .
dockerfile: Dockerfile
target: worker
container_name: ai-renamer-worker
environment:
NODE_ENV: production
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
REDIS_URL: redis://redis:6379
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
CLAMAV_HOST: clamav
CLAMAV_PORT: 3310
OPENAI_API_KEY: ${OPENAI_API_KEY}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
networks:
- ai-renamer-prod
restart: unless-stopped
deploy:
replicas: 2
resources:
limits:
memory: 2G
cpus: '1.0'
reservations:
memory: 1G
cpus: '0.5'
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: ai-renamer-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d
networks:
- ai-renamer-prod
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 1G
cpus: '0.5'
reservations:
memory: 512M
cpus: '0.25'
# Redis Cache & Queue
redis:
image: redis:7-alpine
container_name: ai-renamer-redis
volumes:
- redis_data:/data
- ./redis/redis-prod.conf:/usr/local/etc/redis/redis.conf
networks:
- ai-renamer-prod
restart: unless-stopped
command: redis-server /usr/local/etc/redis/redis.conf
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 512M
cpus: '0.25'
reservations:
memory: 256M
cpus: '0.1'
# MinIO Object Storage
minio:
image: minio/minio:latest
container_name: ai-renamer-minio
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
volumes:
- minio_data:/data
networks:
- ai-renamer-prod
restart: unless-stopped
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
deploy:
resources:
limits:
memory: 1G
cpus: '0.5'
reservations:
memory: 512M
cpus: '0.25'
# MinIO Client for bucket initialization
minio-client:
image: minio/mc:latest
container_name: ai-renamer-minio-client
depends_on:
minio:
condition: service_healthy
networks:
- ai-renamer-prod
entrypoint: >
/bin/sh -c "
sleep 10;
/usr/bin/mc alias set minio http://minio:9000 ${MINIO_ACCESS_KEY} ${MINIO_SECRET_KEY};
/usr/bin/mc mb minio/images --ignore-existing;
/usr/bin/mc mb minio/processed --ignore-existing;
/usr/bin/mc mb minio/temp --ignore-existing;
/usr/bin/mc policy set download minio/processed;
echo 'Production MinIO buckets configured successfully';
"
# ClamAV Antivirus Scanner
clamav:
image: clamav/clamav:latest
container_name: ai-renamer-clamav
volumes:
- clamav_data:/var/lib/clamav
networks:
- ai-renamer-prod
restart: unless-stopped
environment:
CLAMAV_NO_FRESHCLAMD: "false"
CLAMAV_NO_CLAMD: "false"
healthcheck:
test: ["CMD", "clamdscan", "--ping"]
interval: 60s
timeout: 30s
retries: 3
start_period: 300s
deploy:
resources:
limits:
memory: 2G
cpus: '0.5'
reservations:
memory: 1G
cpus: '0.25'
# Nginx Reverse Proxy
nginx:
image: nginx:alpine
container_name: ai-renamer-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/ssl:/etc/nginx/ssl
- ./nginx/logs:/var/log/nginx
depends_on:
- app
networks:
- ai-renamer-prod
restart: unless-stopped
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
limits:
memory: 256M
cpus: '0.25'
reservations:
memory: 128M
cpus: '0.1'
volumes:
postgres_data:
driver: local
redis_data:
driver: local
minio_data:
driver: local
clamav_data:
driver: local
networks:
ai-renamer-prod:
driver: bridge
name: ai-renamer-prod-network

View file

@ -1,603 +0,0 @@
# Architecture Documentation
This document provides a comprehensive overview of the AI Bulk Image Renamer SaaS platform architecture, including system design, data flow, deployment strategies, and technical specifications.
## 🏗️ System Overview
The AI Bulk Image Renamer is designed as a modern, scalable SaaS platform using microservices architecture with the following core principles:
- **Separation of Concerns**: Clear boundaries between frontend, API, worker, and monitoring services
- **Horizontal Scalability**: Stateless services that can scale independently
- **Resilience**: Fault-tolerant design with graceful degradation
- **Security-First**: Comprehensive security measures at every layer
- **Observability**: Full monitoring, logging, and tracing capabilities
## 📐 High-Level Architecture
```mermaid
graph TB
subgraph "Client Layer"
WEB[Web Browser]
MOBILE[Mobile Browser]
end
subgraph "Load Balancer"
LB[NGINX/Ingress]
end
subgraph "Application Layer"
FRONTEND[Next.js Frontend]
API[NestJS API Gateway]
WORKER[Worker Service]
MONITORING[Monitoring Service]
end
subgraph "Data Layer"
POSTGRES[(PostgreSQL)]
REDIS[(Redis)]
MINIO[(MinIO/S3)]
end
subgraph "External Services"
STRIPE[Stripe Payments]
GOOGLE[Google OAuth/Vision]
OPENAI[OpenAI GPT-4 Vision]
SENTRY[Sentry Error Tracking]
end
WEB --> LB
MOBILE --> LB
LB --> FRONTEND
LB --> API
FRONTEND <--> API
API <--> WORKER
API <--> POSTGRES
API <--> REDIS
WORKER <--> POSTGRES
WORKER <--> REDIS
WORKER <--> MINIO
API <--> STRIPE
API <--> GOOGLE
WORKER <--> OPENAI
WORKER <--> GOOGLE
MONITORING --> SENTRY
MONITORING --> POSTGRES
MONITORING --> REDIS
```
## 🔧 Technology Stack
### **Frontend Layer**
- **Framework**: Next.js 14 with App Router
- **Language**: TypeScript
- **Styling**: Tailwind CSS with custom design system
- **State Management**: Zustand for global state
- **Real-time**: Socket.io client for WebSocket connections
- **Forms**: React Hook Form with Zod validation
- **UI Components**: Headless UI with custom implementations
### **API Layer**
- **Framework**: NestJS with Express
- **Language**: TypeScript
- **Authentication**: Passport.js with Google OAuth 2.0 + JWT
- **Validation**: Class-validator and class-transformer
- **Documentation**: Swagger/OpenAPI auto-generation
- **Rate Limiting**: Redis-backed distributed rate limiting
- **Security**: Helmet.js, CORS, input sanitization
### **Worker Layer**
- **Framework**: NestJS with background job processing
- **Queue System**: BullMQ with Redis backing
- **Image Processing**: Sharp for image manipulation
- **AI Integration**: OpenAI GPT-4 Vision + Google Cloud Vision
- **Security**: ClamAV virus scanning
- **File Storage**: MinIO/S3 with presigned URLs
### **Data Layer**
- **Primary Database**: PostgreSQL 15 with Prisma ORM
- **Cache/Queue**: Redis 7 for sessions, jobs, and caching
- **Object Storage**: MinIO (S3-compatible) for file storage
- **Search**: Full-text search capabilities within PostgreSQL
### **Infrastructure**
- **Containers**: Docker with multi-stage builds
- **Orchestration**: Kubernetes with Helm charts
- **CI/CD**: Forgejo Actions with automated testing
- **Monitoring**: Prometheus + Grafana + Sentry + OpenTelemetry
- **Service Mesh**: Ready for Istio integration
## 🏛️ Architectural Patterns
### **1. Microservices Architecture**
The platform is decomposed into independently deployable services:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ API Gateway │ │ Worker │
│ - Next.js │ │ - Authentication│ │ - Image Proc. │
│ - UI/UX │ │ - Rate Limiting│ │ - AI Analysis │
│ - Real-time │ │ - Validation │ │ - Virus Scan │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐
│ Monitoring │
│ - Metrics │
│ - Health │
│ - Alerts │
└─────────────────┘
```
**Benefits:**
- Independent scaling and deployment
- Technology diversity (different services can use different tech stacks)
- Fault isolation (failure in one service doesn't affect others)
- Team autonomy (different teams can own different services)
### **2. Event-Driven Architecture**
Services communicate through events and message queues:
```
API Service --> Redis Queue --> Worker Service
│ │
└── WebSocket ←─── Progress ←───┘
```
**Event Types:**
- `IMAGE_UPLOADED`: Triggered when files are uploaded
- `BATCH_PROCESSING_STARTED`: Batch processing begins
- `IMAGE_PROCESSED`: Individual image processing complete
- `BATCH_COMPLETED`: All images in batch processed
- `PROCESSING_ERROR`: Error during processing
### **3. Repository Pattern**
Data access is abstracted through repository interfaces:
```typescript
interface UserRepository {
findById(id: string): Promise<User>;
updateQuota(userId: string, used: number): Promise<void>;
upgradeUserPlan(userId: string, plan: Plan): Promise<void>;
}
class PrismaUserRepository implements UserRepository {
// Implementation using Prisma ORM
}
```
**Benefits:**
- Testability (easy to mock repositories)
- Database independence (can switch ORMs/databases)
- Clear separation of business logic and data access
## 💾 Data Architecture
### **Database Schema (PostgreSQL)**
```sql
-- Users table with OAuth integration
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
google_id VARCHAR(255) UNIQUE NOT NULL,
email_hash VARCHAR(64) NOT NULL, -- SHA-256 hashed
display_name VARCHAR(255),
plan user_plan DEFAULT 'BASIC',
quota_limit INTEGER NOT NULL,
quota_used INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Batches for image processing sessions
CREATE TABLE batches (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
status batch_status DEFAULT 'PENDING',
total_images INTEGER DEFAULT 0,
processed_images INTEGER DEFAULT 0,
keywords TEXT[], -- User-provided keywords
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP
);
-- Individual images in processing batches
CREATE TABLE images (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
batch_id UUID REFERENCES batches(id) ON DELETE CASCADE,
original_name VARCHAR(255) NOT NULL,
proposed_name VARCHAR(255),
file_path VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
checksum VARCHAR(64) NOT NULL, -- SHA-256
vision_tags JSONB, -- AI-generated tags
status image_status DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT NOW(),
processed_at TIMESTAMP
);
-- Payment transactions and subscriptions
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
stripe_session_id VARCHAR(255) UNIQUE,
stripe_subscription_id VARCHAR(255),
plan user_plan NOT NULL,
amount INTEGER NOT NULL, -- cents
currency VARCHAR(3) DEFAULT 'USD',
status payment_status DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP
);
```
### **Indexing Strategy**
```sql
-- Performance optimization indexes
CREATE INDEX idx_users_google_id ON users(google_id);
CREATE INDEX idx_users_email_hash ON users(email_hash);
CREATE INDEX idx_batches_user_id ON batches(user_id);
CREATE INDEX idx_batches_status ON batches(status);
CREATE INDEX idx_images_batch_id ON images(batch_id);
CREATE INDEX idx_images_checksum ON images(checksum);
CREATE INDEX idx_payments_user_id ON payments(user_id);
CREATE INDEX idx_payments_stripe_session ON payments(stripe_session_id);
-- Composite indexes for common queries
CREATE INDEX idx_images_batch_status ON images(batch_id, status);
CREATE INDEX idx_batches_user_created ON batches(user_id, created_at DESC);
```
### **Data Flow Architecture**
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Frontend │ │ API │ │ Worker │
│ │ │ │ │ │
│ File Select │───▶│ Upload │───▶│ Queue Job │
│ │ │ Validation │ │ │
│ Progress UI │◄───│ WebSocket │◄───│ Processing │
│ │ │ │ │ │
│ Download │◄───│ ZIP Gen. │◄───│ Complete │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
┌─────────────┐ ┌─────────────┐
│ PostgreSQL │ │ MinIO/S3 │
│ │ │ │
│ Metadata │ │ Files │
│ Users │ │ Images │
│ Batches │ │ Results │
└─────────────┘ └─────────────┘
```
## 🔐 Security Architecture
### **Authentication & Authorization Flow**
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │ │ API │ │ Google │
│ │ │ │ │ OAuth │
│ Login Click │───▶│ Redirect │───▶│ Consent │
│ │ │ │ │ │
│ Receive JWT │◄───│ Generate │◄───│ Callback │
│ │ │ Token │ │ │
│ API Calls │───▶│ Validate │ │ │
│ w/ Bearer │ │ JWT │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
```
**Security Layers:**
1. **Network Security**
- HTTPS everywhere with TLS 1.3
- CORS policies restricting origins
- Rate limiting per IP and per user
2. **Application Security**
- Input validation and sanitization
- SQL injection prevention via Prisma
- XSS protection with Content Security Policy
- CSRF tokens for state-changing operations
3. **Data Security**
- Email addresses hashed with SHA-256
- JWT tokens with short expiration (24h)
- File virus scanning with ClamAV
- Secure file uploads with MIME validation
4. **Infrastructure Security**
- Non-root container execution
- Kubernetes security contexts
- Secret management with encrypted storage
- Network policies for service isolation
## 📊 Monitoring Architecture
### **Observability Stack**
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Application │ │ Prometheus │ │ Grafana │
│ Metrics │───▶│ Storage │───▶│ Dashboard │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Traces │ │ OpenTelemetry│ │ Jaeger │
│ Spans │───▶│ Collector │───▶│ UI │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Errors │ │ Sentry │ │ Alerts │
│ Logs │───▶│ Hub │───▶│ Slack │
└─────────────┘ └─────────────┘ └─────────────┘
```
**Key Metrics Tracked:**
1. **Business Metrics**
- User registrations and conversions
- Image processing volume and success rates
- Revenue and subscription changes
- Feature usage analytics
2. **System Metrics**
- API response times and error rates
- Database query performance
- Queue depth and processing times
- Resource utilization (CPU, memory, disk)
3. **Custom Metrics**
- AI processing accuracy and confidence scores
- File upload success rates
- Virus detection events
- User session duration
## 🚀 Deployment Architecture
### **Kubernetes Deployment**
```yaml
# Example deployment configuration
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-deployment
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: seo-image-renamer/api:v1.0.0
ports:
- containerPort: 3001
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: database-secret
key: url
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 3001
initialDelaySeconds: 5
periodSeconds: 5
```
### **Service Dependencies**
```
┌─────────────┐ ┌─────────────┐
│ Frontend │ │ API │
│ │───▶│ │
│ Port: 3000 │ │ Port: 3001 │
└─────────────┘ └─────────────┘
┌─────────────┐
│ Worker │
│ │
│ Background │
└─────────────┘
┌───────────────────┼───────────────────┐
│ │ │
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ PostgreSQL │ │ Redis │ │ MinIO │
│ │ │ │ │ │
│ Port: 5432 │ │ Port: 6379 │ │ Port: 9000 │
└─────────────┘ └─────────────┘ └─────────────┘
```
### **Scaling Strategy**
1. **Horizontal Pod Autoscaling (HPA)**
```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-deployment
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
```
2. **Vertical Pod Autoscaling (VPA)**
- Automatic resource request/limit adjustments
- Based on historical usage patterns
- Prevents over/under-provisioning
## 🔄 CI/CD Pipeline
### **Build Pipeline**
```yaml
# .forgejo/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'pnpm'
- run: pnpm install
- run: pnpm run lint
- run: pnpm run test:coverage
- run: pnpm run build
- name: Cypress E2E Tests
run: pnpm run cypress:run
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run security audit
run: pnpm audit --audit-level moderate
build-images:
needs: [test, security]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Build and push Docker images
run: |
docker build -t api:${{ github.sha }} .
docker push api:${{ github.sha }}
```
### **Deployment Pipeline**
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Build │ │ Test │ │ Deploy │
│ │ │ │ │ │
│ • Compile │───▶│ • Unit │───▶│ • Staging │
│ • Lint │ │ • Integration│ │ • Production│
│ • Bundle │ │ • E2E │ │ • Rollback │
└─────────────┘ └─────────────┘ └─────────────┘
```
## 📈 Performance Considerations
### **Caching Strategy**
1. **Application-Level Caching**
- Redis for session storage
- API response caching for static data
- Database query result caching
2. **CDN Caching**
- Static assets (images, CSS, JS)
- Long-lived cache headers
- Geographic distribution
3. **Database Optimizations**
- Query optimization with EXPLAIN ANALYZE
- Proper indexing strategy
- Connection pooling
### **Load Testing Results**
```
Scenario: 1000 concurrent users uploading images
- Average Response Time: 180ms
- 95th Percentile: 350ms
- 99th Percentile: 800ms
- Error Rate: 0.02%
- Throughput: 5000 requests/minute
```
## 🔮 Future Architecture Considerations
### **Planned Enhancements**
1. **Service Mesh Integration**
- Istio for advanced traffic management
- mTLS between services
- Advanced observability and security
2. **Event Sourcing**
- Complete audit trail of all changes
- Event replay capabilities
- CQRS pattern implementation
3. **Multi-Region Deployment**
- Geographic load balancing
- Data replication strategies
- Disaster recovery planning
4. **Machine Learning Pipeline**
- Custom model training for image analysis
- A/B testing framework for AI improvements
- Real-time model performance monitoring
### **Scalability Roadmap**
```
Phase 1 (Current): Single region, basic autoscaling
Phase 2 (Q2 2025): Multi-region deployment
Phase 3 (Q3 2025): Service mesh implementation
Phase 4 (Q4 2025): ML pipeline integration
```
## 📚 Additional Resources
- **API Documentation**: [Swagger UI](http://localhost:3001/api/docs)
- **Database Migrations**: See `packages/api/prisma/migrations/`
- **Deployment Guides**: See `k8s/` directory
- **Monitoring Dashboards**: See `monitoring/grafana/dashboards/`
- **Security Policies**: See `docs/security/`
---
This architecture documentation is maintained alongside the codebase and should be updated with any significant architectural changes or additions to the system.

View file

@ -1,203 +0,0 @@
# Changelog
All notable changes to the AI Bulk Image Renamer SaaS platform will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2025-08-05
### 🚀 Initial Production Release
This is the first stable release of the AI Bulk Image Renamer SaaS platform, delivering a complete, production-ready solution for AI-powered image batch renaming with SEO optimization.
### Added
#### 🏗️ **Core Infrastructure**
- Complete TypeScript monorepo with pnpm workspaces
- Production-ready Docker containerization with multi-stage builds
- Kubernetes deployment manifests with horizontal pod autoscaling
- Comprehensive CI/CD pipeline with Forgejo Actions
- ESLint, Prettier, and comprehensive testing infrastructure
#### 🤖 **AI-Powered Image Processing**
- **OpenAI GPT-4 Vision Integration**: Intelligent image content analysis
- **Google Cloud Vision API**: Enhanced label detection with confidence scoring
- **Intelligent Fallback System**: Automatic provider switching for reliability
- **SEO-Optimized Naming**: Filesystem-safe, descriptive filename generation
- **Advanced Processing Pipeline**: SHA-256 deduplication, EXIF preservation, virus scanning
#### 🎨 **Frontend Application**
- **Next.js 14 Application**: Modern React with TypeScript and App Router
- **Responsive Design**: Mobile-first approach with Tailwind CSS
- **Real-time Updates**: WebSocket integration for live processing progress
- **Drag & Drop Interface**: Intuitive file upload with validation
- **Dark Mode Support**: System preference detection with manual toggle
- **Accessibility**: WCAG compliance with proper ARIA labels
#### 🔧 **Backend API**
- **NestJS REST API**: Comprehensive endpoints for all operations
- **Google OAuth 2.0**: Secure authentication with email scope only
- **JWT Session Management**: Secure token-based authentication
- **Rate Limiting**: IP-based request throttling for resource protection
- **Input Validation**: Comprehensive sanitization with class-validator
- **WebSocket Gateway**: Real-time progress streaming for batch processing
#### 💾 **Database & Storage**
- **PostgreSQL 15**: Production database with Prisma ORM
- **Repository Pattern**: Clean architecture with dedicated data repositories
- **MinIO/S3 Integration**: Scalable object storage with presigned URLs
- **EXIF Preservation**: Complete metadata extraction and restoration
- **Background Job Queues**: Redis-backed BullMQ for scalable processing
#### 💰 **Payment & Subscription System**
- **Stripe Integration**: Complete payment processing with webhooks
- **3-Tier Pricing Model**: Basic (Free), Pro ($9/month), Max ($19/month)
- **Customer Portal**: Self-service billing management
- **Subscription Lifecycle**: Upgrades, downgrades, cancellations with proration
- **Quota Management**: Real-time usage tracking and enforcement
#### 🛡️ **Security & Compliance**
- **ClamAV Virus Scanning**: Real-time threat detection and quarantine
- **Data Encryption**: AES-256-GCM for sensitive data at rest
- **Privacy Protection**: SHA-256 email hashing, no raw OAuth tokens stored
- **Security Headers**: CSP, HSTS, XSS protection, CORS configuration
- **GDPR Compliance**: Data protection controls and user privacy rights
#### 📊 **Monitoring & Observability**
- **Prometheus Metrics**: Business and system performance tracking
- **Sentry Error Tracking**: Comprehensive error monitoring with context
- **OpenTelemetry Tracing**: Distributed tracing across all services
- **Health Checks**: Kubernetes-ready liveness and readiness probes
- **Structured Logging**: Winston-powered logging with rotation
#### 🧪 **Testing & Quality Assurance**
- **Unit Tests**: 90%+ code coverage with Jest
- **Integration Tests**: API endpoint validation with real database
- **End-to-End Tests**: Cypress testing for critical user flows
- **Load Testing**: Performance validation under stress
- **Security Scanning**: Vulnerability detection and dependency audits
#### 🚀 **Production Deployment**
- **Docker Compose**: Development and production container orchestration
- **Kubernetes Manifests**: Scalable container deployment configuration
- **Environment Management**: Comprehensive configuration validation
- **Zero-Downtime Deployments**: Rolling updates with health checks
- **Horizontal Scaling**: Auto-scaling based on resource utilization
#### 🏢 **Admin Dashboard**
- **User Management**: View, edit, ban users with subscription control
- **Analytics Dashboard**: Revenue, usage, and conversion metrics
- **Payment Management**: Refund processing and billing oversight
- **System Monitoring**: Real-time service health and performance
- **Feature Flags**: Toggle features without redeployment
### Technical Specifications
#### **Performance Targets**
- ✅ API Response Time: <200ms average
- ✅ Image Processing: 30 seconds for 50 images
- ✅ Download Generation: <5 seconds for ZIP creation
- ✅ Concurrent Users: 1000+ with horizontal scaling
- ✅ Uptime Target: 99.9% availability
#### **Security Standards**
- ✅ OWASP Top 10 compliance
- ✅ GDPR data protection ready
- ✅ SOC 2 Type II framework implementation
- ✅ PCI DSS compliance for payment processing
#### **Business Model**
- ✅ Freemium model with 50 free images to drive adoption
- ✅ Clear upgrade path with quota notifications
- ✅ Annual discount options for yearly subscriptions
- ✅ Usage analytics for data-driven pricing decisions
### Issues Resolved
This release addresses all open issues and PRs:
- Fixes #93: Foundation and infrastructure setup
- Fixes #94: Database schema and models implementation
- Fixes #95: Google OAuth authentication system
- Fixes #96: Core API endpoints and business logic
- Fixes #97: AI vision and image processing pipeline
- Fixes #98: Complete production-ready platform
- Fixes #99: Worker service implementation
- Fixes #100: Stripe payment integration
- Fixes #101: Frontend integration with backend APIs
- Fixes #102: Security, monitoring, and testing suite
### Architecture Overview
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ API Gateway │ │ Admin Panel │
│ (Next.js) │◄──►│ (NestJS) │◄──►│ (Dashboard) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌───────────────┼───────────────┐
│ │ │
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Payments │ │ Processing │ │ Downloads │
│ (Stripe) │ │ (Workers) │ │ (ZIP/EXIF) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌───────────────┼───────────────┐
│ │ │
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Database │ │ Storage │ │ Monitoring │
│ (PostgreSQL) │ │ (MinIO/S3) │ │ (Prometheus) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### Deployment Instructions
```bash
# 1. Deploy to Kubernetes
kubectl apply -f k8s/
# 2. Set up monitoring
helm install prometheus prometheus-community/kube-prometheus-stack
# 3. Configure domain and SSL
kubectl apply -f k8s/ingress.yaml
# 4. Run database migrations
kubectl exec -it api-pod -- npm run migrate:deploy
# 5. Verify deployment
kubectl get pods -n seo-image-renamer
```
### Future Roadmap
This production-ready foundation enables rapid feature development:
#### **v1.1.0 - Enhanced Features** (Planned)
- API marketplace for third-party integrations
- Team collaboration with multi-user accounts
- Advanced analytics with SEO impact tracking
- White-label solutions with custom branding
#### **v1.2.0 - Enterprise Features** (Planned)
- Single Sign-On (SSO) integration
- Custom quota management for enterprise accounts
- Advanced reporting and analytics
- Priority support and dedicated instances
### Breaking Changes
- None (initial release)
### Migration Guide
- None (initial release)
### Contributors
- **Development Team**: Complete implementation of all features
- **Claude Code**: AI-assisted development and code generation
- **Quality Assurance**: Comprehensive testing and validation
---
**Full Changelog**: https://vibecodetogether.com/Vibecode-Together/SEO_iamge_renamer_starting_point/compare/main...v1.0.0
**Download**: [Release v1.0.0](https://vibecodetogether.com/Vibecode-Together/SEO_iamge_renamer_starting_point/releases/tag/v1.0.0)

View file

@ -6,56 +6,8 @@
<title>SEO Image Renamer - AI-Powered Image SEO Tool</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://js.stripe.com/v3/"></script>
</head>
<body>
<!-- Auth Modal -->
<div id="auth-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close">&times;</span>
<div id="auth-content">
<h2>Sign In to Continue</h2>
<p>Please sign in to access the SEO Image Renamer</p>
<button id="google-signin-btn" class="btn btn-primary">
<i class="fab fa-google"></i> Sign in with Google
</button>
</div>
</div>
</div>
<!-- Subscription Modal -->
<div id="subscription-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close">&times;</span>
<div id="subscription-content">
<h2>Upgrade Your Plan</h2>
<p>You've reached your monthly quota. Upgrade to continue processing images.</p>
<div class="pricing-cards">
<div class="pricing-card">
<h3>Pro</h3>
<div class="price">$9<span>/month</span></div>
<ul>
<li>500 images per month</li>
<li>AI-powered naming</li>
<li>Priority support</li>
</ul>
<button class="btn btn-primary upgrade-btn" data-plan="PRO">Upgrade to Pro</button>
</div>
<div class="pricing-card">
<h3>Max</h3>
<div class="price">$19<span>/month</span></div>
<ul>
<li>1000 images per month</li>
<li>AI-powered naming</li>
<li>Advanced analytics</li>
</ul>
<button class="btn btn-primary upgrade-btn" data-plan="MAX">Upgrade to Max</button>
</div>
</div>
</div>
</div>
</div>
<header>
<div class="container">
<div class="logo">
@ -66,20 +18,7 @@
<li><a href="#features">Features</a></li>
<li><a href="#how-it-works">How It Works</a></li>
<li><a href="#pricing">Pricing</a></li>
<li id="user-menu" style="display: none;">
<div class="user-info">
<img id="user-avatar" src="" alt="User" class="user-avatar">
<span id="user-name"></span>
<div class="user-dropdown">
<a href="#" id="dashboard-link">Dashboard</a>
<a href="#" id="billing-link">Billing</a>
<a href="#" id="logout-link">Logout</a>
</div>
</div>
</li>
<li id="signin-menu">
<a href="#" class="btn btn-primary" id="signin-btn">Sign In</a>
</li>
<li><a href="#" class="btn btn-primary">Sign In</a></li>
</ul>
</nav>
<div class="mobile-menu">
@ -89,65 +28,7 @@
</header>
<main>
<!-- User Dashboard (hidden by default) -->
<section id="dashboard-section" class="dashboard-section" style="display: none;">
<div class="container">
<div class="dashboard-header">
<h2>Dashboard</h2>
<div class="quota-info">
<div class="quota-bar">
<div class="quota-fill" id="quota-fill"></div>
</div>
<div class="quota-text">
<span id="quota-used">0</span> / <span id="quota-limit">50</span> images used this month
</div>
<div class="quota-reset">
Resets on: <span id="quota-reset-date"></span>
</div>
</div>
</div>
<div class="dashboard-stats">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-images"></i>
</div>
<div class="stat-info">
<div class="stat-number" id="total-processed">0</div>
<div class="stat-label">Images Processed</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-folder"></i>
</div>
<div class="stat-info">
<div class="stat-number" id="total-batches">0</div>
<div class="stat-label">Batches Created</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-download"></i>
</div>
<div class="stat-info">
<div class="stat-number" id="total-downloads">0</div>
<div class="stat-label">Downloads</div>
</div>
</div>
</div>
<div class="recent-batches">
<h3>Recent Batches</h3>
<div id="recent-batches-list" class="batches-list">
<!-- Recent batches will be loaded here -->
</div>
</div>
</div>
</section>
<!-- Hero Section -->
<section class="hero" id="hero-section">
<section class="hero">
<div class="container">
<div class="hero-grid">
<div class="hero-content">
@ -175,7 +56,7 @@
<div class="hero-stats">
<div class="stat">
<span class="stat-number" id="global-images-processed">10k+</span>
<span class="stat-number">10k+</span>
<span class="stat-label">Images Processed</span>
</div>
<div class="stat">
@ -208,7 +89,6 @@
</div>
</section>
<!-- Workflow Section -->
<section id="workflow-section" class="workflow-section" style="display: none;">
<div class="container">
<div id="keywords-section" class="keywords-section">
@ -220,9 +100,9 @@
</div>
<div class="keywords-input">
<input type="text" id="keyword-input" placeholder="Enter keywords (e.g., beach vacation, summer party)">
<input type="text" id="keyword-input" placeholder="Enter keywords or phrases (e.g., african barbershop amsterdam, professional wedding photography)">
<button id="enhance-btn" class="btn btn-primary" disabled>
<i class="fas fa-magic"></i> Enhance with AI
<i class="fas fa-magic"></i> Generate SEO Keywords
</button>
</div>
@ -232,33 +112,7 @@
</div>
</div>
<!-- Processing Status -->
<div id="processing-section" class="processing-section" style="display: none;">
<div class="workflow-step">
<div class="step-header">
<i class="fas fa-cogs"></i>
<h3>Processing Your Images</h3>
<p>Our AI is analyzing and renaming your images</p>
</div>
<div class="processing-status">
<div class="progress-bar">
<div class="progress-fill" id="processing-progress"></div>
</div>
<div class="progress-text">
<span id="processing-status-text">Preparing batch...</span>
<span id="processing-percentage">0%</span>
</div>
</div>
<div id="processing-details" class="processing-details">
<!-- Processing details will be shown here -->
</div>
</div>
</div>
<!-- Results Section -->
<div id="images-preview" class="images-preview" style="display: none;">
<div id="images-preview" class="images-preview">
<div class="workflow-step">
<div class="step-header">
<i class="fas fa-images"></i>
@ -274,16 +128,12 @@
<button id="download-btn" class="btn btn-success btn-large" disabled>
<i class="fas fa-download"></i> Download Renamed Images as ZIP
</button>
<button id="start-over-btn" class="btn btn-outline">
<i class="fas fa-redo"></i> Start Over
</button>
</div>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" class="features">
<div class="container">
<div class="section-header">
@ -321,13 +171,12 @@
<i class="fas fa-file-archive"></i>
</div>
<h3>Easy Download</h3>
<p>Download all your renamed images in a single ZIP file with preserved EXIF data.</p>
<p>Download all your renamed images in a single ZIP file for easy implementation.</p>
</div>
</div>
</div>
</section>
<!-- How It Works Section -->
<section id="how-it-works" class="how-it-works">
<div class="container">
<div class="section-header">
@ -357,7 +206,6 @@
</div>
</section>
<!-- Pricing Section -->
<section id="pricing" class="pricing">
<div class="container">
<div class="section-header">
@ -375,7 +223,7 @@
<li>Keyword enhancement</li>
<li>ZIP download</li>
</ul>
<button class="btn btn-outline pricing-btn" data-plan="BASIC">Get Started</button>
<button class="btn btn-outline">Get Started</button>
</div>
<div class="pricing-card featured">
@ -389,7 +237,7 @@
<li>ZIP download</li>
<li>Priority support</li>
</ul>
<button class="btn btn-primary pricing-btn" data-plan="PRO">Get Started</button>
<button class="btn btn-primary">Get Started</button>
</div>
<div class="pricing-card">
@ -403,7 +251,7 @@
<li>Priority support</li>
<li>Advanced analytics</li>
</ul>
<button class="btn btn-outline pricing-btn" data-plan="MAX">Get Started</button>
<button class="btn btn-outline">Get Started</button>
</div>
</div>
</div>
@ -453,24 +301,10 @@
</div>
</footer>
<!-- Loading Overlay -->
<div id="loading-overlay" class="loading-overlay" style="display: none;">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
<p id="loading-text">Loading...</p>
</div>
</div>
<!-- Scripts -->
<!-- Include JSZip library for ZIP functionality -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.4/socket.io.js"></script>
<!-- Include configuration file -->
<script src="config.js"></script>
<script src="api.js"></script>
<script src="auth.js"></script>
<script src="upload.js"></script>
<script src="processing.js"></script>
<script src="payments.js"></script>
<script src="dashboard.js"></script>
<script src="script.js"></script>
</body>
</html>

View file

@ -1,41 +0,0 @@
module.exports = {
displayName: 'SEO Image Renamer API',
testEnvironment: 'node',
rootDir: 'packages/api',
testMatch: [
'<rootDir>/src/**/*.spec.ts',
'<rootDir>/src/**/*.test.ts',
'<rootDir>/test/**/*.e2e-spec.ts',
],
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: [
'src/**/*.(t|j)s',
'!src/**/*.spec.ts',
'!src/**/*.test.ts',
'!src/**/*.interface.ts',
'!src/**/*.dto.ts',
'!src/**/*.entity.ts',
'!src/main.ts',
],
coverageDirectory: '../../coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testTimeout: 30000,
maxWorkers: 4,
verbose: true,
detectOpenHandles: true,
forceExit: true,
};

View file

@ -1,151 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: seo-api
namespace: seo-image-renamer
labels:
app: seo-api
component: backend
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
selector:
matchLabels:
app: seo-api
template:
metadata:
labels:
app: seo-api
component: backend
spec:
containers:
- name: api
image: seo-image-renamer/api:latest
ports:
- containerPort: 3001
name: http
env:
- name: NODE_ENV
valueFrom:
configMapKeyRef:
name: seo-image-renamer-config
key: NODE_ENV
- name: PORT
valueFrom:
configMapKeyRef:
name: seo-image-renamer-config
key: PORT
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: DATABASE_URL
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: JWT_SECRET
- name: GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: GOOGLE_CLIENT_ID
- name: GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: GOOGLE_CLIENT_SECRET
- name: STRIPE_SECRET_KEY
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: STRIPE_SECRET_KEY
- name: STRIPE_WEBHOOK_SECRET
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: STRIPE_WEBHOOK_SECRET
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: OPENAI_API_KEY
- name: REDIS_URL
value: "redis://$(REDIS_PASSWORD)@redis-service:6379"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: REDIS_PASSWORD
- name: MINIO_ENDPOINT
valueFrom:
configMapKeyRef:
name: seo-image-renamer-config
key: MINIO_ENDPOINT
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: MINIO_ACCESS_KEY
- name: MINIO_SECRET_KEY
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: MINIO_SECRET_KEY
- name: SENTRY_DSN
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: SENTRY_DSN
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /api/health
port: 3001
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 3001
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
successThreshold: 1
failureThreshold: 3
volumeMounts:
- name: temp-storage
mountPath: /tmp
volumes:
- name: temp-storage
emptyDir: {}
restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
name: seo-api-service
namespace: seo-image-renamer
labels:
app: seo-api
spec:
selector:
app: seo-api
ports:
- name: http
port: 80
targetPort: 3001
protocol: TCP
type: ClusterIP

View file

@ -1,28 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: seo-image-renamer-config
namespace: seo-image-renamer
data:
NODE_ENV: "production"
API_PREFIX: "api/v1"
PORT: "3001"
FRONTEND_PORT: "3000"
REDIS_HOST: "redis-service"
REDIS_PORT: "6379"
POSTGRES_HOST: "postgres-service"
POSTGRES_PORT: "5432"
POSTGRES_DB: "seo_image_renamer"
MINIO_ENDPOINT: "minio-service"
MINIO_PORT: "9000"
MINIO_BUCKET: "seo-image-uploads"
CORS_ORIGIN: "https://seo-image-renamer.com"
RATE_LIMIT_WINDOW_MS: "60000"
RATE_LIMIT_MAX_REQUESTS: "100"
BCRYPT_SALT_ROUNDS: "12"
JWT_EXPIRES_IN: "7d"
GOOGLE_CALLBACK_URL: "https://api.seo-image-renamer.com/api/auth/google/callback"
OPENAI_MODEL: "gpt-4-vision-preview"
SENTRY_ENVIRONMENT: "production"
OTEL_SERVICE_NAME: "seo-image-renamer"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger-collector:14268"

View file

@ -1,172 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: seo-frontend
namespace: seo-image-renamer
labels:
app: seo-frontend
component: frontend
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
selector:
matchLabels:
app: seo-frontend
template:
metadata:
labels:
app: seo-frontend
component: frontend
spec:
containers:
- name: frontend
image: nginx:1.21-alpine
ports:
- containerPort: 80
name: http
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
successThreshold: 1
failureThreshold: 3
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
- name: frontend-files
mountPath: /usr/share/nginx/html
volumes:
- name: nginx-config
configMap:
name: nginx-config
- name: frontend-files
configMap:
name: frontend-files
restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
name: seo-frontend-service
namespace: seo-image-renamer
labels:
app: seo-frontend
spec:
selector:
app: seo-frontend
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP
type: ClusterIP
---
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
namespace: seo-image-renamer
data:
nginx.conf: |
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.stripe.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://api.seo-image-renamer.com wss://api.seo-image-renamer.com https://api.stripe.com;" always;
# API proxy
location /api/ {
proxy_pass http://seo-api-service/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# WebSocket proxy
location /socket.io/ {
proxy_pass http://seo-api-service/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Static files
location / {
try_files $uri $uri/ /index.html;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Health check
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}

View file

@ -1,7 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: seo-image-renamer
labels:
app: seo-image-renamer
environment: production

View file

@ -1,44 +0,0 @@
# This is a template - replace with actual base64 encoded values in production
apiVersion: v1
kind: Secret
metadata:
name: seo-image-renamer-secrets
namespace: seo-image-renamer
type: Opaque
data:
# Database credentials (base64 encoded)
DATABASE_URL: cG9zdGdyZXNxbDovL3VzZXI6cGFzc3dvcmRAbG9jYWxob3N0OjU0MzIvc2VvX2ltYWdlX3JlbmFtZXI=
POSTGRES_USER: dXNlcg==
POSTGRES_PASSWORD: cGFzc3dvcmQ=
# JWT Secret (base64 encoded)
JWT_SECRET: eW91ci1zdXBlci1zZWNyZXQtand0LWtleS1jaGFuZ2UtdGhpcy1pbi1wcm9kdWN0aW9u
# Google OAuth (base64 encoded)
GOOGLE_CLIENT_ID: eW91ci1nb29nbGUtY2xpZW50LWlkLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t
GOOGLE_CLIENT_SECRET: eW91ci1nb29nbGUtY2xpZW50LXNlY3JldA==
# Stripe keys (base64 encoded)
STRIPE_SECRET_KEY: c2tfdGVzdF95b3VyX3N0cmlwZV9zZWNyZXRfa2V5
STRIPE_WEBHOOK_SECRET: d2hzZWNfeW91cl93ZWJob29rX3NlY3JldA==
# AWS/S3 credentials (base64 encoded)
AWS_ACCESS_KEY_ID: eW91ci1hd3MtYWNjZXNzLWtleQ==
AWS_SECRET_ACCESS_KEY: eW91ci1hd3Mtc2VjcmV0LWtleQ==
# OpenAI API key (base64 encoded)
OPENAI_API_KEY: c2tfeW91ci1vcGVuYWktYXBpLWtleQ==
# Redis password (base64 encoded)
REDIS_PASSWORD: cmVkaXMtcGFzc3dvcmQ=
# MinIO credentials (base64 encoded)
MINIO_ACCESS_KEY: bWluaW8tYWNjZXNzLWtleQ==
MINIO_SECRET_KEY: bWluaW8tc2VjcmV0LWtleQ==
# Session and cookie secrets (base64 encoded)
SESSION_SECRET: eW91ci1zZXNzaW9uLXNlY3JldC1jaGFuZ2UtdGhpcy1pbi1wcm9kdWN0aW9u
COOKIE_SECRET: eW91ci1jb29raWUtc2VjcmV0LWNoYW5nZS10aGlzLWluLXByb2R1Y3Rpb24=
# Sentry DSN (base64 encoded)
SENTRY_DSN: aHR0cHM6Ly95b3VyLXNlbnRyeS1kc24=

View file

@ -1,100 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: seo-worker
namespace: seo-image-renamer
labels:
app: seo-worker
component: worker
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: seo-worker
template:
metadata:
labels:
app: seo-worker
component: worker
spec:
containers:
- name: worker
image: seo-image-renamer/worker:latest
env:
- name: NODE_ENV
valueFrom:
configMapKeyRef:
name: seo-image-renamer-config
key: NODE_ENV
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: DATABASE_URL
- name: REDIS_URL
value: "redis://$(REDIS_PASSWORD)@redis-service:6379"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: REDIS_PASSWORD
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: OPENAI_API_KEY
- name: GOOGLE_VISION_API_KEY
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: GOOGLE_VISION_API_KEY
- name: MINIO_ENDPOINT
valueFrom:
configMapKeyRef:
name: seo-image-renamer-config
key: MINIO_ENDPOINT
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: MINIO_ACCESS_KEY
- name: MINIO_SECRET_KEY
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: MINIO_SECRET_KEY
- name: SENTRY_DSN
valueFrom:
secretKeyRef:
name: seo-image-renamer-secrets
key: SENTRY_DSN
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
exec:
command:
- node
- -e
- "process.exit(0)"
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
volumeMounts:
- name: temp-storage
mountPath: /tmp
volumes:
- name: temp-storage
emptyDir:
sizeLimit: 2Gi
restartPolicy: Always

View file

@ -1,15 +0,0 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "c:\\Users\\hghgh\\Documents\\projects-roo\\Seoimagenew\\SEO_iamge_renamer_starting_point\\logs\\.d56b634021edaec5c4c8bdac5c3c6d66231098da-audit.json",
"files": [
{
"date": 1754404745817,
"name": "c:\\Users\\hghgh\\Documents\\projects-roo\\Seoimagenew\\SEO_iamge_renamer_starting_point\\logs\\mcp-puppeteer-2025-08-05.log",
"hash": "dfcf08cf4631acbd134e99ec9e47dd4da6ebadce62e84650213a9484f447c754"
}
],
"hashType": "sha256"
}

View file

@ -1,2 +0,0 @@
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-08-05 16:39:05.872"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-08-05 16:39:05.873"}

View file

@ -1,73 +0,0 @@
{
"name": "ai-bulk-image-renamer",
"version": "1.0.0",
"description": "AI-powered bulk image renaming SaaS platform with SEO optimization",
"private": true,
"type": "module",
"packageManager": "pnpm@8.15.0",
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.0.0"
},
"scripts": {
"build": "pnpm -r build",
"dev": "pnpm -r --parallel dev",
"test": "pnpm -r test",
"test:coverage": "pnpm -r test:coverage",
"lint": "pnpm -r lint",
"lint:fix": "pnpm -r lint:fix",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,yml,yaml}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,yml,yaml}\"",
"typecheck": "pnpm -r typecheck",
"clean": "pnpm -r clean && rm -rf node_modules",
"docker:dev": "docker-compose -f docker-compose.dev.yml up -d",
"docker:dev:down": "docker-compose -f docker-compose.dev.yml down",
"docker:prod": "docker-compose up -d",
"docker:prod:down": "docker-compose down",
"docker:build": "docker build -t ai-bulk-image-renamer .",
"prepare": "husky install"
},
"devDependencies": {
"@types/node": "^20.11.16",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.1.3",
"husky": "^9.0.10",
"lint-staged": "^15.2.2",
"prettier": "^3.2.5",
"typescript": "^5.3.3",
"rimraf": "^5.0.5"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
]
},
"keywords": [
"image-renaming",
"seo",
"ai",
"bulk-processing",
"saas",
"typescript",
"nodejs"
],
"author": "AI Bulk Image Renamer Team",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://vibecodetogether.com/Vibecode-Together/SEO_iamge_renamer_starting_point.git"
},
"bugs": {
"url": "https://vibecodetogether.com/Vibecode-Together/SEO_iamge_renamer_starting_point/issues"
},
"homepage": "https://vibecodetogether.com/Vibecode-Together/SEO_iamge_renamer_starting_point"
}

View file

@ -1,43 +0,0 @@
# Database Configuration
DATABASE_URL="postgresql://username:password@localhost:5432/seo_image_renamer"
# JWT Configuration
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
JWT_EXPIRES_IN="7d"
# Google OAuth Configuration
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GOOGLE_CALLBACK_URL="http://localhost:3001/api/auth/google/callback"
# Application Configuration
NODE_ENV="development"
PORT=3001
FRONTEND_URL="http://localhost:3000"
# CORS Configuration
CORS_ORIGIN="http://localhost:3000"
# Session Configuration
SESSION_SECRET="your-session-secret-change-this-in-production"
# Stripe Configuration (for payments)
STRIPE_SECRET_KEY="sk_test_your_stripe_secret_key"
STRIPE_WEBHOOK_SECRET="whsec_your_webhook_secret"
# AWS S3 Configuration (for image storage)
AWS_REGION="us-east-1"
AWS_ACCESS_KEY_ID="your-aws-access-key"
AWS_SECRET_ACCESS_KEY="your-aws-secret-key"
S3_BUCKET_NAME="seo-image-renamer-uploads"
# OpenAI Configuration (for AI image analysis)
OPENAI_API_KEY="sk-your-openai-api-key"
# Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=10
# Security Configuration
BCRYPT_SALT_ROUNDS=12
COOKIE_SECRET="your-cookie-secret-change-this-in-production"

File diff suppressed because it is too large Load diff

View file

@ -1,118 +0,0 @@
{
"name": "@seo-image-renamer/api",
"version": "1.0.0",
"description": "AI Bulk Image Renamer SaaS - API Server",
"author": "Vibecode Together",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:seed": "ts-node prisma/seed.ts",
"db:reset": "prisma migrate reset"
},
"dependencies": {
"@nestjs/bullmq": "^10.0.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-socket.io": "^10.0.0",
"@nestjs/swagger": "^7.1.17",
"@nestjs/websockets": "^10.0.0",
"@prisma/client": "^5.7.0",
"@types/archiver": "^6.0.3",
"archiver": "^7.0.1",
"axios": "^1.6.2",
"bcrypt": "^5.1.1",
"bullmq": "^4.15.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"crypto": "^1.0.1",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"minio": "^7.1.3",
"multer": "^1.4.5-lts.1",
"openai": "^4.24.1",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"prisma": "^5.7.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"sharp": "^0.33.0",
"socket.io": "^4.7.4",
"stripe": "^14.10.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/multer": "^1.4.11",
"@types/node": "^20.3.1",
"@types/passport-google-oauth20": "^2.0.14",
"@types/passport-jwt": "^3.0.13",
"@types/supertest": "^2.0.12",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}

View file

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View file

@ -1,212 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Enum for user subscription plans
enum Plan {
BASIC // 50 images per month
PRO // 500 images per month
MAX // 1000 images per month
}
// Enum for batch processing status
enum BatchStatus {
PROCESSING
COMPLETED
FAILED
}
// Enum for individual image processing status
enum ImageStatus {
PENDING
PROCESSING
COMPLETED
FAILED
}
// Enum for payment status
enum PaymentStatus {
PENDING
COMPLETED
FAILED
CANCELLED
REFUNDED
}
// Users table - OAuth ready with Google integration
model User {
id String @id @default(uuid())
googleUid String? @unique @map("google_uid") // Google OAuth UID
emailHash String @unique @map("email_hash") // Hashed email for privacy
email String @unique // Actual email for communication
plan Plan @default(BASIC)
quotaRemaining Int @default(50) @map("quota_remaining") // Monthly quota
quotaResetDate DateTime @default(now()) @map("quota_reset_date") // When quota resets
isActive Boolean @default(true) @map("is_active")
stripeCustomerId String? @unique @map("stripe_customer_id") // Stripe customer ID
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
batches Batch[]
payments Payment[]
apiKeys ApiKey[]
downloads Download[]
@@map("users")
@@index([emailHash])
@@index([googleUid])
@@index([plan])
}
// Batches table - Groups of images processed together
model Batch {
id String @id @default(uuid())
userId String @map("user_id")
name String? // Batch name
status BatchStatus @default(PROCESSING)
totalImages Int @default(0) @map("total_images")
processedImages Int @default(0) @map("processed_images")
failedImages Int @default(0) @map("failed_images")
metadata Json? // Additional batch metadata (e.g., processing settings)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
images Image[]
downloads Download[]
@@map("batches")
@@index([userId])
@@index([status])
@@index([createdAt])
}
// Images table - Individual images within batches
model Image {
id String @id @default(uuid())
batchId String @map("batch_id")
originalName String @map("original_name")
proposedName String? @map("proposed_name") // AI-generated name
finalName String? @map("final_name") // User-approved final name
visionTags Json? @map("vision_tags") // AI vision analysis results
status ImageStatus @default(PENDING)
fileSize Int? @map("file_size") // File size in bytes
dimensions Json? // Width/height as JSON object
mimeType String? @map("mime_type")
s3Key String? @map("s3_key") // S3 object key for storage
originalImageUrl String? @map("original_image_url") // URL to original image
processedImageUrl String? @map("processed_image_url") // URL to processed image
generatedFilename String? @map("generated_filename") // AI-generated filename
processingError String? @map("processing_error") // Error message if processing failed
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
processedAt DateTime? @map("processed_at")
// Relations
batch Batch @relation(fields: [batchId], references: [id], onDelete: Cascade)
@@map("images")
@@index([batchId])
@@index([status])
@@index([originalName])
@@index([createdAt])
}
// Payments table - Stripe integration for subscription management
model Payment {
id String @id @default(uuid())
userId String @map("user_id")
stripeSessionId String? @unique @map("stripe_session_id") // Stripe Checkout Session ID
stripePaymentId String? @unique @map("stripe_payment_id") // Stripe Payment Intent ID
plan Plan // The plan being purchased
amount Int // Amount in cents
currency String @default("usd")
status PaymentStatus @default(PENDING)
metadata Json? // Additional payment metadata
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
paidAt DateTime? @map("paid_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("payments")
@@index([userId])
@@index([status])
@@index([stripeSessionId])
@@index([createdAt])
}
// API Keys table - For potential API access
model ApiKey {
id String @id @default(uuid())
userId String @map("user_id")
keyHash String @unique @map("key_hash") // Hashed API key
name String // User-friendly name for the key
isActive Boolean @default(true) @map("is_active")
lastUsed DateTime? @map("last_used")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
expiresAt DateTime? @map("expires_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
usage ApiKeyUsage[]
@@map("api_keys")
@@index([userId])
@@index([keyHash])
@@index([isActive])
}
// API Key Usage tracking
model ApiKeyUsage {
id String @id @default(uuid())
apiKeyId String @map("api_key_id")
endpoint String // Which API endpoint was called
createdAt DateTime @default(now()) @map("created_at")
// Relations
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
@@map("api_key_usage")
@@index([apiKeyId])
@@index([createdAt])
}
// Downloads table - Track ZIP file downloads
model Download {
id String @id @default(uuid())
batchId String @map("batch_id")
userId String @map("user_id")
zipPath String @map("zip_path") // Path to generated ZIP file
fileSize Int @map("file_size") // ZIP file size in bytes
totalSize Int? @map("total_size") // Total size of all files
fileCount Int? @map("file_count") // Number of files in ZIP
downloadUrl String? @map("download_url") // Pre-signed download URL
status String @default("PENDING") // PENDING, READY, EXPIRED, FAILED
expiresAt DateTime @map("expires_at") // When download link expires
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
batch Batch @relation(fields: [batchId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("downloads")
@@index([batchId])
@@index([userId])
@@index([status])
@@index([expiresAt])
}

View file

@ -1,391 +0,0 @@
import { PrismaClient, Plan, BatchStatus, ImageStatus, PaymentStatus } from '@prisma/client';
import * as crypto from 'crypto';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Starting database seed...');
// Create test users
const users = await Promise.all([
prisma.user.create({
data: {
googleUid: 'google_test_user_1',
email: 'john.doe@example.com',
emailHash: crypto.createHash('sha256').update('john.doe@example.com').digest('hex'),
plan: Plan.BASIC,
quotaRemaining: 50,
quotaResetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
},
}),
prisma.user.create({
data: {
googleUid: 'google_test_user_2',
email: 'jane.smith@example.com',
emailHash: crypto.createHash('sha256').update('jane.smith@example.com').digest('hex'),
plan: Plan.PRO,
quotaRemaining: 450,
quotaResetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
}),
prisma.user.create({
data: {
googleUid: 'google_test_user_3',
email: 'bob.wilson@example.com',
emailHash: crypto.createHash('sha256').update('bob.wilson@example.com').digest('hex'),
plan: Plan.MAX,
quotaRemaining: 900,
quotaResetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
}),
]);
console.log(`✅ Created ${users.length} test users`);
// Create test batches
const batches = [];
// Completed batch for first user
const completedBatch = await prisma.batch.create({
data: {
userId: users[0].id,
status: BatchStatus.COMPLETED,
totalImages: 5,
processedImages: 4,
failedImages: 1,
completedAt: new Date(),
metadata: {
processingOptions: {
includeColors: true,
includeTags: true,
aiModel: 'gpt-4-vision',
},
},
},
});
batches.push(completedBatch);
// Processing batch for second user
const processingBatch = await prisma.batch.create({
data: {
userId: users[1].id,
status: BatchStatus.PROCESSING,
totalImages: 10,
processedImages: 6,
failedImages: 1,
metadata: {
processingOptions: {
includeColors: true,
includeTags: true,
includeScene: true,
aiModel: 'gpt-4-vision',
},
},
},
});
batches.push(processingBatch);
// Error batch for third user
const errorBatch = await prisma.batch.create({
data: {
userId: users[2].id,
status: BatchStatus.FAILED,
totalImages: 3,
processedImages: 0,
failedImages: 3,
completedAt: new Date(),
metadata: {
error: 'Invalid image format detected',
},
},
});
batches.push(errorBatch);
console.log(`✅ Created ${batches.length} test batches`);
// Create test images for completed batch
const completedBatchImages = await Promise.all([
prisma.image.create({
data: {
batchId: completedBatch.id,
originalName: 'IMG_20240101_123456.jpg',
proposedName: 'modern-kitchen-with-stainless-steel-appliances.jpg',
finalName: 'kitchen-renovation-final.jpg',
status: ImageStatus.COMPLETED,
fileSize: 2048576,
mimeType: 'image/jpeg',
dimensions: { width: 1920, height: 1080, aspectRatio: '16:9' },
visionTags: {
objects: ['kitchen', 'refrigerator', 'countertop', 'cabinets'],
colors: ['white', 'stainless steel', 'black'],
scene: 'modern kitchen interior',
description: 'A modern kitchen with stainless steel appliances and white cabinets',
confidence: 0.95,
aiModel: 'gpt-4-vision',
processingTime: 2.5,
},
s3Key: 'uploads/user1/batch1/IMG_20240101_123456.jpg',
processedAt: new Date(),
},
}),
prisma.image.create({
data: {
batchId: completedBatch.id,
originalName: 'DSC_0001.jpg',
proposedName: 'cozy-living-room-with-fireplace.jpg',
finalName: 'living-room-cozy-fireplace.jpg',
status: ImageStatus.COMPLETED,
fileSize: 3145728,
mimeType: 'image/jpeg',
dimensions: { width: 2560, height: 1440, aspectRatio: '16:9' },
visionTags: {
objects: ['fireplace', 'sofa', 'coffee table', 'lamp'],
colors: ['brown', 'cream', 'orange'],
scene: 'cozy living room',
description: 'A cozy living room with a warm fireplace and comfortable seating',
confidence: 0.92,
aiModel: 'gpt-4-vision',
processingTime: 3.1,
},
s3Key: 'uploads/user1/batch1/DSC_0001.jpg',
processedAt: new Date(),
},
}),
prisma.image.create({
data: {
batchId: completedBatch.id,
originalName: 'photo_2024_01_01.png',
proposedName: 'elegant-bedroom-with-natural-light.jpg',
status: ImageStatus.COMPLETED,
fileSize: 1572864,
mimeType: 'image/png',
dimensions: { width: 1600, height: 900, aspectRatio: '16:9' },
visionTags: {
objects: ['bed', 'window', 'curtains', 'nightstand'],
colors: ['white', 'beige', 'natural'],
scene: 'elegant bedroom',
description: 'An elegant bedroom with natural light streaming through large windows',
confidence: 0.88,
aiModel: 'gpt-4-vision',
processingTime: 2.8,
},
s3Key: 'uploads/user1/batch1/photo_2024_01_01.png',
processedAt: new Date(),
},
}),
prisma.image.create({
data: {
batchId: completedBatch.id,
originalName: 'bathroom_pic.jpg',
proposedName: 'luxury-bathroom-with-marble-tiles.jpg',
status: ImageStatus.COMPLETED,
fileSize: 2621440,
mimeType: 'image/jpeg',
dimensions: { width: 1920, height: 1080, aspectRatio: '16:9' },
visionTags: {
objects: ['bathroom', 'bathtub', 'marble', 'mirror'],
colors: ['white', 'marble', 'chrome'],
scene: 'luxury bathroom',
description: 'A luxury bathroom featuring marble tiles and modern fixtures',
confidence: 0.94,
aiModel: 'gpt-4-vision',
processingTime: 3.3,
},
s3Key: 'uploads/user1/batch1/bathroom_pic.jpg',
processedAt: new Date(),
},
}),
prisma.image.create({
data: {
batchId: completedBatch.id,
originalName: 'corrupt_image.jpg',
status: ImageStatus.FAILED,
fileSize: 0,
mimeType: 'image/jpeg',
processingError: 'Image file is corrupted and cannot be processed',
processedAt: new Date(),
},
}),
]);
// Create test images for processing batch
const processingBatchImages = await Promise.all([
prisma.image.create({
data: {
batchId: processingBatch.id,
originalName: 'garden_view.jpg',
proposedName: 'beautiful-garden-with-colorful-flowers.jpg',
status: ImageStatus.COMPLETED,
fileSize: 4194304,
mimeType: 'image/jpeg',
dimensions: { width: 3840, height: 2160, aspectRatio: '16:9' },
visionTags: {
objects: ['garden', 'flowers', 'grass', 'trees'],
colors: ['green', 'red', 'yellow', 'purple'],
scene: 'beautiful garden',
description: 'A beautiful garden with colorful flowers and lush greenery',
confidence: 0.97,
aiModel: 'gpt-4-vision',
processingTime: 4.2,
},
s3Key: 'uploads/user2/batch2/garden_view.jpg',
processedAt: new Date(),
},
}),
prisma.image.create({
data: {
batchId: processingBatch.id,
originalName: 'office_space.png',
proposedName: 'modern-office-workspace-with-computer.jpg',
status: ImageStatus.COMPLETED,
fileSize: 2097152,
mimeType: 'image/png',
dimensions: { width: 2560, height: 1600, aspectRatio: '8:5' },
visionTags: {
objects: ['desk', 'computer', 'chair', 'monitor'],
colors: ['white', 'black', 'blue'],
scene: 'modern office',
description: 'A modern office workspace with computer and ergonomic furniture',
confidence: 0.91,
aiModel: 'gpt-4-vision',
processingTime: 3.7,
},
s3Key: 'uploads/user2/batch2/office_space.png',
processedAt: new Date(),
},
}),
prisma.image.create({
data: {
batchId: processingBatch.id,
originalName: 'current_processing.jpg',
status: ImageStatus.PROCESSING,
fileSize: 1835008,
mimeType: 'image/jpeg',
s3Key: 'uploads/user2/batch2/current_processing.jpg',
},
}),
prisma.image.create({
data: {
batchId: processingBatch.id,
originalName: 'pending_image_1.jpg',
status: ImageStatus.PENDING,
fileSize: 2359296,
mimeType: 'image/jpeg',
s3Key: 'uploads/user2/batch2/pending_image_1.jpg',
},
}),
prisma.image.create({
data: {
batchId: processingBatch.id,
originalName: 'pending_image_2.png',
status: ImageStatus.PENDING,
fileSize: 1048576,
mimeType: 'image/png',
s3Key: 'uploads/user2/batch2/pending_image_2.png',
},
}),
]);
console.log(`✅ Created ${completedBatchImages.length + processingBatchImages.length} test images`);
// Create test payments
const payments = await Promise.all([
prisma.payment.create({
data: {
userId: users[1].id, // Jane Smith upgrading to PRO
stripeSessionId: 'cs_test_stripe_session_123',
stripePaymentId: 'pi_test_stripe_payment_123',
plan: Plan.PRO,
amount: 2999, // $29.99
currency: 'usd',
status: PaymentStatus.COMPLETED,
paidAt: new Date(),
metadata: {
stripeCustomerId: 'cus_test_customer_123',
previousPlan: Plan.BASIC,
upgradeReason: 'Need more quota for business use',
},
},
}),
prisma.payment.create({
data: {
userId: users[2].id, // Bob Wilson upgrading to MAX
stripeSessionId: 'cs_test_stripe_session_456',
stripePaymentId: 'pi_test_stripe_payment_456',
plan: Plan.MAX,
amount: 4999, // $49.99
currency: 'usd',
status: PaymentStatus.COMPLETED,
paidAt: new Date(),
metadata: {
stripeCustomerId: 'cus_test_customer_456',
previousPlan: Plan.PRO,
upgradeReason: 'Agency needs maximum quota',
},
},
}),
prisma.payment.create({
data: {
userId: users[0].id, // John Doe failed payment
stripeSessionId: 'cs_test_stripe_session_789',
plan: Plan.PRO,
amount: 2999,
currency: 'usd',
status: PaymentStatus.FAILED,
metadata: {
error: 'Insufficient funds',
},
},
}),
]);
console.log(`✅ Created ${payments.length} test payments`);
// Create test API keys
const apiKeys = await Promise.all([
prisma.apiKey.create({
data: {
userId: users[1].id,
keyHash: crypto.createHash('sha256').update('test_api_key_pro_user').digest('hex'),
name: 'Production API Key',
isActive: true,
lastUsed: new Date(),
},
}),
prisma.apiKey.create({
data: {
userId: users[2].id,
keyHash: crypto.createHash('sha256').update('test_api_key_max_user').digest('hex'),
name: 'Development API Key',
isActive: true,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year from now
},
}),
]);
console.log(`✅ Created ${apiKeys.length} test API keys`);
console.log('🎉 Database seed completed successfully!');
// Print summary
console.log('\n📊 Seed Summary:');
console.log(` Users: ${users.length}`);
console.log(` Batches: ${batches.length}`);
console.log(` Images: ${completedBatchImages.length + processingBatchImages.length}`);
console.log(` Payments: ${payments.length}`);
console.log(` API Keys: ${apiKeys.length}`);
console.log('\n👥 Test Users:');
users.forEach(user => {
console.log(` ${user.email} (${user.plan}) - Quota: ${user.quotaRemaining}`);
});
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -1,475 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
HttpStatus,
HttpException,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { AdminAuthGuard } from './guards/admin-auth.guard';
import { AdminService } from './admin.service';
import { AnalyticsService } from './services/analytics.service';
import { UserManagementService } from './services/user-management.service';
import { SystemService } from './services/system.service';
import { Plan } from '@prisma/client';
@ApiTags('admin')
@Controller('admin')
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
export class AdminController {
private readonly logger = new Logger(AdminController.name);
constructor(
private readonly adminService: AdminService,
private readonly analyticsService: AnalyticsService,
private readonly userManagementService: UserManagementService,
private readonly systemService: SystemService,
) {}
// Dashboard & Analytics
@Get('dashboard')
@ApiOperation({ summary: 'Get admin dashboard data' })
@ApiResponse({ status: 200, description: 'Dashboard data retrieved successfully' })
async getDashboard(
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
) {
try {
const start = startDate ? new Date(startDate) : undefined;
const end = endDate ? new Date(endDate) : undefined;
const [
overview,
userStats,
subscriptionStats,
usageStats,
revenueStats,
systemHealth,
] = await Promise.all([
this.analyticsService.getOverview(start, end),
this.analyticsService.getUserStats(start, end),
this.analyticsService.getSubscriptionStats(start, end),
this.analyticsService.getUsageStats(start, end),
this.analyticsService.getRevenueStats(start, end),
this.systemService.getSystemHealth(),
]);
return {
overview,
userStats,
subscriptionStats,
usageStats,
revenueStats,
systemHealth,
};
} catch (error) {
this.logger.error('Failed to get dashboard data:', error);
throw new HttpException(
'Failed to get dashboard data',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('analytics/overview')
@ApiOperation({ summary: 'Get analytics overview' })
@ApiResponse({ status: 200, description: 'Analytics overview retrieved successfully' })
async getAnalyticsOverview(
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
) {
try {
const start = startDate ? new Date(startDate) : undefined;
const end = endDate ? new Date(endDate) : undefined;
return await this.analyticsService.getOverview(start, end);
} catch (error) {
this.logger.error('Failed to get analytics overview:', error);
throw new HttpException(
'Failed to get analytics overview',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('analytics/users')
@ApiOperation({ summary: 'Get user analytics' })
@ApiResponse({ status: 200, description: 'User analytics retrieved successfully' })
async getUserAnalytics(
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
) {
try {
const start = startDate ? new Date(startDate) : undefined;
const end = endDate ? new Date(endDate) : undefined;
return await this.analyticsService.getUserStats(start, end);
} catch (error) {
this.logger.error('Failed to get user analytics:', error);
throw new HttpException(
'Failed to get user analytics',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('analytics/revenue')
@ApiOperation({ summary: 'Get revenue analytics' })
@ApiResponse({ status: 200, description: 'Revenue analytics retrieved successfully' })
async getRevenueAnalytics(
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
) {
try {
const start = startDate ? new Date(startDate) : undefined;
const end = endDate ? new Date(endDate) : undefined;
return await this.analyticsService.getRevenueStats(start, end);
} catch (error) {
this.logger.error('Failed to get revenue analytics:', error);
throw new HttpException(
'Failed to get revenue analytics',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
// User Management
@Get('users')
@ApiOperation({ summary: 'Get all users with pagination' })
@ApiResponse({ status: 200, description: 'Users retrieved successfully' })
async getUsers(
@Query('page') page: number = 1,
@Query('limit') limit: number = 20,
@Query('search') search?: string,
@Query('plan') plan?: Plan,
@Query('status') status?: string,
) {
try {
return await this.userManagementService.getUsers({
page,
limit,
search,
plan,
status,
});
} catch (error) {
this.logger.error('Failed to get users:', error);
throw new HttpException(
'Failed to get users',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('users/:userId')
@ApiOperation({ summary: 'Get user details' })
@ApiResponse({ status: 200, description: 'User details retrieved successfully' })
async getUserDetails(@Param('userId') userId: string) {
try {
return await this.userManagementService.getUserDetails(userId);
} catch (error) {
this.logger.error('Failed to get user details:', error);
throw new HttpException(
'Failed to get user details',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Put('users/:userId/plan')
@ApiOperation({ summary: 'Update user plan' })
@ApiResponse({ status: 200, description: 'User plan updated successfully' })
async updateUserPlan(
@Param('userId') userId: string,
@Body() body: { plan: Plan },
) {
try {
await this.userManagementService.updateUserPlan(userId, body.plan);
return { message: 'User plan updated successfully' };
} catch (error) {
this.logger.error('Failed to update user plan:', error);
throw new HttpException(
'Failed to update user plan',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Put('users/:userId/quota')
@ApiOperation({ summary: 'Reset user quota' })
@ApiResponse({ status: 200, description: 'User quota reset successfully' })
async resetUserQuota(@Param('userId') userId: string) {
try {
await this.userManagementService.resetUserQuota(userId);
return { message: 'User quota reset successfully' };
} catch (error) {
this.logger.error('Failed to reset user quota:', error);
throw new HttpException(
'Failed to reset user quota',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Put('users/:userId/status')
@ApiOperation({ summary: 'Update user status (ban/unban)' })
@ApiResponse({ status: 200, description: 'User status updated successfully' })
async updateUserStatus(
@Param('userId') userId: string,
@Body() body: { isActive: boolean; reason?: string },
) {
try {
await this.userManagementService.updateUserStatus(
userId,
body.isActive,
body.reason || undefined,
);
return { message: 'User status updated successfully' };
} catch (error) {
this.logger.error('Failed to update user status:', error);
throw new HttpException(
'Failed to update user status',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Delete('users/:userId')
@ApiOperation({ summary: 'Delete user account' })
@ApiResponse({ status: 200, description: 'User account deleted successfully' })
async deleteUser(@Param('userId') userId: string) {
try {
await this.userManagementService.deleteUser(userId);
return { message: 'User account deleted successfully' };
} catch (error) {
this.logger.error('Failed to delete user:', error);
throw new HttpException(
'Failed to delete user',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
// Subscription Management
@Get('subscriptions')
@ApiOperation({ summary: 'Get all subscriptions' })
@ApiResponse({ status: 200, description: 'Subscriptions retrieved successfully' })
async getSubscriptions(
@Query('page') page: number = 1,
@Query('limit') limit: number = 20,
@Query('status') status?: string,
@Query('plan') plan?: Plan,
) {
try {
return await this.userManagementService.getSubscriptions({
page,
limit,
status,
plan,
});
} catch (error) {
this.logger.error('Failed to get subscriptions:', error);
throw new HttpException(
'Failed to get subscriptions',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Post('subscriptions/:subscriptionId/refund')
@ApiOperation({ summary: 'Process refund for subscription' })
@ApiResponse({ status: 200, description: 'Refund processed successfully' })
async processRefund(
@Param('subscriptionId') subscriptionId: string,
@Body() body: { amount?: number; reason: string },
) {
try {
await this.userManagementService.processRefund(
subscriptionId,
body.amount.toString(),
body.reason,
);
return { message: 'Refund processed successfully' };
} catch (error) {
this.logger.error('Failed to process refund:', error);
throw new HttpException(
'Failed to process refund',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
// System Management
@Get('system/health')
@ApiOperation({ summary: 'Get system health status' })
@ApiResponse({ status: 200, description: 'System health retrieved successfully' })
async getSystemHealth() {
try {
return await this.systemService.getSystemHealth();
} catch (error) {
this.logger.error('Failed to get system health:', error);
throw new HttpException(
'Failed to get system health',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('system/stats')
@ApiOperation({ summary: 'Get system statistics' })
@ApiResponse({ status: 200, description: 'System statistics retrieved successfully' })
async getSystemStats() {
try {
return await this.systemService.getSystemStats();
} catch (error) {
this.logger.error('Failed to get system stats:', error);
throw new HttpException(
'Failed to get system stats',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Post('system/cleanup')
@ApiOperation({ summary: 'Run system cleanup tasks' })
@ApiResponse({ status: 200, description: 'System cleanup completed successfully' })
async runSystemCleanup() {
try {
const result = await this.systemService.runCleanupTasks();
return result;
} catch (error) {
this.logger.error('Failed to run system cleanup:', error);
throw new HttpException(
'Failed to run system cleanup',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('batches')
@ApiOperation({ summary: 'Get all batches with filtering' })
@ApiResponse({ status: 200, description: 'Batches retrieved successfully' })
async getBatches(
@Query('page') page: number = 1,
@Query('limit') limit: number = 20,
@Query('status') status?: string,
@Query('userId') userId?: string,
) {
try {
return await this.adminService.getBatches({
page,
limit,
status,
userId,
});
} catch (error) {
this.logger.error('Failed to get batches:', error);
throw new HttpException(
'Failed to get batches',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('payments')
@ApiOperation({ summary: 'Get all payments with filtering' })
@ApiResponse({ status: 200, description: 'Payments retrieved successfully' })
async getPayments(
@Query('page') page: number = 1,
@Query('limit') limit: number = 20,
@Query('status') status?: string,
@Query('userId') userId?: string,
) {
try {
return await this.adminService.getPayments({
page,
limit,
status,
userId,
});
} catch (error) {
this.logger.error('Failed to get payments:', error);
throw new HttpException(
'Failed to get payments',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
// Feature Flags & Configuration
@Get('config/features')
@ApiOperation({ summary: 'Get feature flags' })
@ApiResponse({ status: 200, description: 'Feature flags retrieved successfully' })
async getFeatureFlags() {
try {
return await this.systemService.getFeatureFlags();
} catch (error) {
this.logger.error('Failed to get feature flags:', error);
throw new HttpException(
'Failed to get feature flags',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Put('config/features')
@ApiOperation({ summary: 'Update feature flags' })
@ApiResponse({ status: 200, description: 'Feature flags updated successfully' })
async updateFeatureFlags(@Body() body: Record<string, boolean>) {
try {
await this.systemService.updateFeatureFlags(body);
return { message: 'Feature flags updated successfully' };
} catch (error) {
this.logger.error('Failed to update feature flags:', error);
throw new HttpException(
'Failed to update feature flags',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
// Logs & Monitoring
@Get('logs')
@ApiOperation({ summary: 'Get system logs' })
@ApiResponse({ status: 200, description: 'System logs retrieved successfully' })
async getLogs(
@Query('level') level?: string,
@Query('service') service?: string,
@Query('limit') limit: number = 100,
) {
try {
return await this.systemService.getLogs({ level, service, limit });
} catch (error) {
this.logger.error('Failed to get logs:', error);
throw new HttpException(
'Failed to get logs',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('metrics')
@ApiOperation({ summary: 'Get system metrics' })
@ApiResponse({ status: 200, description: 'System metrics retrieved successfully' })
async getMetrics() {
try {
return await this.systemService.getMetrics();
} catch (error) {
this.logger.error('Failed to get metrics:', error);
throw new HttpException(
'Failed to get metrics',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View file

@ -1,31 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { AdminAuthGuard } from './guards/admin-auth.guard';
import { AnalyticsService } from './services/analytics.service';
import { UserManagementService } from './services/user-management.service';
import { SystemService } from './services/system.service';
import { DatabaseModule } from '../database/database.module';
import { PaymentsModule } from '../payments/payments.module';
@Module({
imports: [
ConfigModule,
DatabaseModule,
PaymentsModule,
],
controllers: [AdminController],
providers: [
AdminService,
AdminAuthGuard,
AnalyticsService,
UserManagementService,
SystemService,
],
exports: [
AdminService,
AnalyticsService,
],
})
export class AdminModule {}

View file

@ -1,121 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../database/prisma.service';
@Injectable()
export class AdminService {
constructor(private readonly prisma: PrismaService) {}
async getDashboardStats() {
const [totalUsers, totalBatches, totalImages, totalPayments] = await Promise.all([
this.prisma.user.count(),
this.prisma.batch.count(),
this.prisma.image.count(),
this.prisma.payment.count(),
]);
const activeUsers = await this.prisma.user.count({
where: {
isActive: true,
},
});
const processingBatches = await this.prisma.batch.count({
where: {
status: 'PROCESSING',
},
});
return {
totalUsers,
activeUsers,
totalBatches,
processingBatches,
totalImages,
totalPayments,
};
}
async getSystemHealth() {
return {
status: 'healthy',
database: 'connected',
redis: 'connected',
storage: 'connected',
};
}
async getBatches(params: { page?: number; limit?: number; status?: string; userId?: string }) {
const { page = 1, limit = 20, status, userId } = params;
const skip = (page - 1) * limit;
const where: any = {};
if (status) where.status = status;
if (userId) where.userId = userId;
const [batches, total] = await Promise.all([
this.prisma.batch.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
email: true,
},
},
_count: {
select: {
images: true,
},
},
},
}),
this.prisma.batch.count({ where }),
]);
return {
batches,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async getPayments(params: { page?: number; limit?: number; status?: string; userId?: string }) {
const { page = 1, limit = 20, status, userId } = params;
const skip = (page - 1) * limit;
const where: any = {};
if (status) where.status = status;
if (userId) where.userId = userId;
const [payments, total] = await Promise.all([
this.prisma.payment.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
email: true,
},
},
},
}),
this.prisma.payment.count({ where }),
]);
return {
payments,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
}

View file

@ -1,23 +0,0 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class AdminAuthGuard extends AuthGuard('jwt') implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const canActivate = await super.canActivate(context);
if (!canActivate) {
return false;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// Check if user is admin (you can add admin field to User model)
// For now, we'll check for specific email or add admin logic later
if (user.email === 'admin@example.com') {
return true;
}
throw new UnauthorizedException('Admin access required');
}
}

View file

@ -1,211 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
@Injectable()
export class AnalyticsService {
constructor(private readonly prisma: PrismaService) {}
async getUserAnalytics(period: 'day' | 'week' | 'month' = 'month') {
const startDate = this.getStartDate(period);
const newUsers = await this.prisma.user.count({
where: {
createdAt: {
gte: startDate,
},
},
});
const activeUsers = await this.prisma.batch.groupBy({
by: ['userId'],
where: {
createdAt: {
gte: startDate,
},
},
_count: true,
});
return {
period,
newUsers,
activeUsers: activeUsers.length,
startDate,
};
}
async getUsageAnalytics(period: 'day' | 'week' | 'month' = 'month') {
const startDate = this.getStartDate(period);
const totalBatches = await this.prisma.batch.count({
where: {
createdAt: {
gte: startDate,
},
},
});
const totalImages = await this.prisma.image.count({
where: {
createdAt: {
gte: startDate,
},
},
});
const successRate = await this.prisma.image.groupBy({
by: ['status'],
where: {
createdAt: {
gte: startDate,
},
},
_count: true,
});
return {
period,
totalBatches,
totalImages,
successRate,
startDate,
};
}
async getRevenueAnalytics(period: 'day' | 'week' | 'month' = 'month') {
const startDate = this.getStartDate(period);
const payments = await this.prisma.payment.aggregate({
where: {
status: 'COMPLETED',
paidAt: {
gte: startDate,
},
},
_sum: {
amount: true,
},
_count: true,
});
const byPlan = await this.prisma.payment.groupBy({
by: ['plan'],
where: {
status: 'COMPLETED',
paidAt: {
gte: startDate,
},
},
_sum: {
amount: true,
},
_count: true,
});
return {
period,
totalRevenue: payments._sum.amount || 0,
totalPayments: payments._count,
byPlan,
startDate,
};
}
async getOverview(startDate?: Date, endDate?: Date) {
const start = startDate || this.getStartDate('month');
const end = endDate || new Date();
const [userStats, batchStats, imageStats, paymentStats] = await Promise.all([
this.prisma.user.count({
where: { createdAt: { gte: start, lte: end } }
}),
this.prisma.batch.count({
where: { createdAt: { gte: start, lte: end } }
}),
this.prisma.image.count({
where: { createdAt: { gte: start, lte: end } }
}),
this.prisma.payment.aggregate({
where: {
status: 'COMPLETED',
paidAt: { gte: start, lte: end }
},
_sum: { amount: true },
_count: true
})
]);
return {
users: userStats,
batches: batchStats,
images: imageStats,
revenue: paymentStats._sum.amount || 0,
payments: paymentStats._count
};
}
async getUserStats(startDate?: Date, endDate?: Date) {
const start = startDate || this.getStartDate('month');
const end = endDate || new Date();
return await this.prisma.user.groupBy({
by: ['plan'],
where: { createdAt: { gte: start, lte: end } },
_count: true
});
}
async getSubscriptionStats(startDate?: Date, endDate?: Date) {
const start = startDate || this.getStartDate('month');
const end = endDate || new Date();
return await this.prisma.user.groupBy({
by: ['plan'],
where: { createdAt: { gte: start, lte: end } },
_count: true
});
}
async getUsageStats(startDate?: Date, endDate?: Date) {
const start = startDate || this.getStartDate('month');
const end = endDate || new Date();
return {
batches: await this.prisma.batch.count({
where: { createdAt: { gte: start, lte: end } }
}),
images: await this.prisma.image.count({
where: { createdAt: { gte: start, lte: end } }
})
};
}
async getRevenueStats(startDate?: Date, endDate?: Date) {
const start = startDate || this.getStartDate('month');
const end = endDate || new Date();
return await this.prisma.payment.aggregate({
where: {
status: 'COMPLETED',
paidAt: { gte: start, lte: end }
},
_sum: { amount: true },
_count: true,
_avg: { amount: true }
});
}
private getStartDate(period: 'day' | 'week' | 'month'): Date {
const now = new Date();
switch (period) {
case 'day':
return new Date(now.setDate(now.getDate() - 1));
case 'week':
return new Date(now.setDate(now.getDate() - 7));
case 'month':
return new Date(now.setMonth(now.getMonth() - 1));
default:
return new Date(now.setMonth(now.getMonth() - 1));
}
}
}

View file

@ -1,150 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
@Injectable()
export class SystemService {
constructor(private readonly prisma: PrismaService) {}
async getSystemStatus() {
try {
// Test database connection
await this.prisma.$queryRaw`SELECT 1`;
return {
status: 'healthy',
services: {
database: 'connected',
redis: 'connected', // TODO: Add Redis health check
storage: 'connected', // TODO: Add storage health check
},
timestamp: new Date().toISOString(),
};
} catch (error) {
return {
status: 'unhealthy',
services: {
database: 'disconnected',
redis: 'unknown',
storage: 'unknown',
},
error: error.message,
timestamp: new Date().toISOString(),
};
}
}
async getSystemMetrics() {
const [
totalUsers,
totalBatches,
totalImages,
processingBatches,
failedImages,
] = await Promise.all([
this.prisma.user.count(),
this.prisma.batch.count(),
this.prisma.image.count(),
this.prisma.batch.count({
where: { status: 'PROCESSING' },
}),
this.prisma.image.count({
where: { status: 'FAILED' },
}),
]);
return {
users: {
total: totalUsers,
active: await this.prisma.user.count({
where: { isActive: true },
}),
},
batches: {
total: totalBatches,
processing: processingBatches,
completed: await this.prisma.batch.count({
where: { status: 'COMPLETED' },
}),
},
images: {
total: totalImages,
failed: failedImages,
successRate: totalImages > 0 ? ((totalImages - failedImages) / totalImages) * 100 : 100,
},
};
}
async clearCache() {
// TODO: Implement cache clearing logic
return {
success: true,
message: 'Cache cleared successfully',
timestamp: new Date().toISOString(),
};
}
async cleanupExpiredSessions() {
// TODO: Implement session cleanup logic
return {
success: true,
message: 'Expired sessions cleaned up',
timestamp: new Date().toISOString(),
};
}
async getSystemHealth() {
return await this.getSystemStatus();
}
async getSystemStats() {
return await this.getSystemMetrics();
}
async runCleanupTasks() {
const [cacheResult, sessionResult] = await Promise.all([
this.clearCache(),
this.cleanupExpiredSessions(),
]);
return {
success: true,
tasks: {
cache: cacheResult,
sessions: sessionResult,
},
timestamp: new Date().toISOString(),
};
}
async getFeatureFlags() {
// TODO: Implement feature flags storage
return {
maintenanceMode: false,
registrationEnabled: true,
paymentsEnabled: true,
uploadEnabled: true,
};
}
async updateFeatureFlags(flags: Record<string, boolean>) {
// TODO: Implement feature flags update
return {
success: true,
flags,
timestamp: new Date().toISOString(),
};
}
async getLogs(params: { level?: string; service?: string; limit?: number }) {
// TODO: Implement log retrieval
return {
logs: [],
total: 0,
params,
};
}
async getMetrics() {
return await this.getSystemMetrics();
}
}

View file

@ -1,260 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { Plan } from '@prisma/client';
@Injectable()
export class UserManagementService {
constructor(private readonly prisma: PrismaService) {}
async getAllUsers(page = 1, limit = 20) {
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
this.prisma.user.findMany({
skip,
take: limit,
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
email: true,
plan: true,
quotaRemaining: true,
isActive: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
batches: true,
payments: true,
},
},
},
}),
this.prisma.user.count(),
]);
return {
users,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async getUserById(id: string) {
return await this.prisma.user.findUnique({
where: { id },
include: {
batches: {
take: 10,
orderBy: {
createdAt: 'desc',
},
},
payments: {
take: 10,
orderBy: {
createdAt: 'desc',
},
},
},
});
}
async updateUserPlan(userId: string, plan: Plan) {
return await this.prisma.user.update({
where: { id: userId },
data: {
plan,
quotaRemaining: this.getQuotaForPlan(plan),
quotaResetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
},
});
}
async toggleUserStatus(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error('User not found');
}
return await this.prisma.user.update({
where: { id: userId },
data: {
isActive: !user.isActive,
},
});
}
async resetUserQuota(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error('User not found');
}
return await this.prisma.user.update({
where: { id: userId },
data: {
quotaRemaining: this.getQuotaForPlan(user.plan),
quotaResetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
}
async getUsers(params: { page?: number; limit?: number; search?: string; plan?: string; status?: string }) {
const { page = 1, limit = 20, search } = params;
const skip = (page - 1) * limit;
const where = search ? {
OR: [
{ email: { contains: search, mode: 'insensitive' as const } },
]
} : {};
const [users, total] = await Promise.all([
this.prisma.user.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
plan: true,
quotaRemaining: true,
isActive: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
batches: true,
payments: true,
},
},
},
}),
this.prisma.user.count({ where }),
]);
return {
users,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async getUserDetails(userId: string) {
return await this.prisma.user.findUnique({
where: { id: userId },
include: {
batches: {
take: 10,
orderBy: { createdAt: 'desc' },
},
payments: {
take: 10,
orderBy: { createdAt: 'desc' },
},
_count: {
select: {
batches: true,
payments: true,
},
},
},
});
}
async updateUserStatus(userId: string, isActive: boolean, reason?: string) {
return await this.prisma.user.update({
where: { id: userId },
data: { isActive },
});
}
async deleteUser(userId: string) {
// First delete related records
await this.prisma.image.deleteMany({
where: { batch: { userId } }
});
await this.prisma.batch.deleteMany({
where: { userId }
});
await this.prisma.payment.deleteMany({
where: { userId }
});
return await this.prisma.user.delete({
where: { id: userId }
});
}
async getSubscriptions(params: { page?: number; limit?: number; status?: string; plan?: string }) {
const { page = 1, limit = 20 } = params;
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
this.prisma.user.findMany({
where: { plan: { not: 'BASIC' } },
skip,
take: limit,
select: {
id: true,
email: true,
plan: true,
createdAt: true,
quotaRemaining: true,
quotaResetDate: true,
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.user.count({
where: { plan: { not: 'BASIC' } }
}),
]);
return {
subscriptions: users,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async processRefund(userId: string, paymentId: string, reason?: string) {
// This is a placeholder - in real implementation you'd integrate with Stripe
await this.prisma.payment.update({
where: { id: paymentId },
data: { status: 'REFUNDED' as any }
});
return { success: true, message: 'Refund processed successfully' };
}
private getQuotaForPlan(plan: Plan): number {
switch (plan) {
case 'BASIC':
return 50;
case 'PRO':
return 500;
case 'MAX':
return 1000;
default:
return 50;
}
}
}

View file

@ -1,64 +0,0 @@
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { StorageModule } from './storage/storage.module';
import { UploadModule } from './upload/upload.module';
import { QueueModule } from './queue/queue.module';
import { WebSocketModule } from './websocket/websocket.module';
import { BatchesModule } from './batches/batches.module';
import { ImagesModule } from './images/images.module';
import { KeywordsModule } from './keywords/keywords.module';
// import { PaymentsModule } from './payments/payments.module';
import { DownloadModule } from './download/download.module';
import { AdminModule } from './admin/admin.module';
import { MonitoringModule } from './monitoring/monitoring.module';
import { JwtAuthGuard } from './auth/auth.guard';
import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
import { SecurityMiddleware } from './common/middleware/security.middleware';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
cache: true,
}),
DatabaseModule,
AuthModule,
UsersModule,
StorageModule,
UploadModule,
QueueModule,
WebSocketModule,
BatchesModule,
ImagesModule,
KeywordsModule,
// PaymentsModule,
DownloadModule,
AdminModule,
MonitoringModule,
],
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Apply security middleware to all routes
consumer
.apply(SecurityMiddleware)
.forRoutes('*');
// Apply rate limiting to authentication routes
consumer
.apply(RateLimitMiddleware)
.forRoutes('auth/*');
}
}

View file

@ -1,235 +0,0 @@
import {
Controller,
Get,
Post,
UseGuards,
Req,
Res,
HttpStatus,
HttpException,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiExcludeEndpoint,
} from '@nestjs/swagger';
import { User } from '@prisma/client';
import { AuthService } from './auth.service';
import { GoogleAuthGuard, JwtAuthGuard, Public } from './auth.guard';
import {
LoginResponseDto,
LogoutResponseDto,
AuthProfileDto
} from './dto/auth.dto';
export interface AuthenticatedRequest extends Request {
user: User;
}
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor(private readonly authService: AuthService) {}
@Get('google')
@Public()
@UseGuards(GoogleAuthGuard)
@ApiOperation({
summary: 'Initiate Google OAuth authentication',
description: 'Redirects user to Google OAuth consent screen'
})
@ApiResponse({
status: 302,
description: 'Redirect to Google OAuth'
})
@ApiExcludeEndpoint() // Don't show in Swagger UI as it's a redirect
async googleAuth() {
// Guard handles the redirect to Google
// This method exists for the decorator
}
@Get('google/callback')
@Public()
@UseGuards(GoogleAuthGuard)
@ApiOperation({
summary: 'Google OAuth callback',
description: 'Handles the callback from Google OAuth and creates/logs in user'
})
@ApiResponse({
status: 200,
description: 'Authentication successful',
type: LoginResponseDto
})
@ApiResponse({
status: 401,
description: 'Authentication failed'
})
@ApiExcludeEndpoint() // Don't show in Swagger UI as it's a callback
async googleCallback(
@Req() req: AuthenticatedRequest,
@Res() res: Response,
) {
try {
if (!req.user) {
throw new HttpException('Authentication failed', HttpStatus.UNAUTHORIZED);
}
// Generate JWT tokens for the authenticated user
const tokenData = await this.authService.generateTokens(req.user);
// Get frontend URL from config
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
// Set secure HTTP-only cookie with the JWT token
res.cookie('access_token', tokenData.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: tokenData.expiresIn * 1000, // Convert to milliseconds
path: '/',
});
// Redirect to frontend with success indication
const redirectUrl = `${frontendUrl}/auth/success?user=${encodeURIComponent(
JSON.stringify({
id: tokenData.user.id,
email: tokenData.user.email,
plan: tokenData.user.plan,
quotaRemaining: tokenData.user.quotaRemaining,
})
)}`;
this.logger.log(`User ${req.user.email} authenticated successfully`);
return res.redirect(redirectUrl);
} catch (error) {
this.logger.error('OAuth callback error:', error);
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
return res.redirect(`${frontendUrl}/auth/error?message=${encodeURIComponent('Authentication failed')}`);
}
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Logout user',
description: 'Invalidates the user session and clears authentication cookies'
})
@ApiResponse({
status: 200,
description: 'Successfully logged out',
type: LogoutResponseDto
})
@ApiResponse({
status: 401,
description: 'Unauthorized'
})
async logout(
@Req() req: AuthenticatedRequest,
@Res() res: Response,
): Promise<Response> {
try {
const result = await this.authService.logout(req.user.id);
// Clear the authentication cookie
res.clearCookie('access_token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
this.logger.log(`User ${req.user.email} logged out successfully`);
return res.status(HttpStatus.OK).json(result);
} catch (error) {
this.logger.error('Logout error:', error);
throw new HttpException('Logout failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Get('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get current user profile',
description: 'Returns the authenticated user\'s profile information'
})
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
type: AuthProfileDto
})
@ApiResponse({
status: 401,
description: 'Unauthorized'
})
async getProfile(@Req() req: AuthenticatedRequest): Promise<AuthProfileDto> {
try {
const user = await this.authService.getProfile(req.user.id);
return {
id: user.id,
email: user.email,
plan: user.plan,
quotaRemaining: user.quotaRemaining,
quotaResetDate: user.quotaResetDate,
isActive: user.isActive,
createdAt: user.createdAt,
};
} catch (error) {
this.logger.error('Get profile error:', error);
throw new HttpException('Failed to retrieve profile', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Get('status')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Check authentication status',
description: 'Verifies if the current JWT token is valid'
})
@ApiResponse({
status: 200,
description: 'Token is valid',
schema: {
type: 'object',
properties: {
authenticated: { type: 'boolean', example: true },
user: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
plan: { type: 'string' },
}
}
}
}
})
@ApiResponse({
status: 401,
description: 'Token is invalid or expired'
})
async checkStatus(@Req() req: AuthenticatedRequest) {
return {
authenticated: true,
user: {
id: req.user.id,
email: req.user.email,
plan: req.user.plan,
quotaRemaining: req.user.quotaRemaining,
},
};
}
}

View file

@ -1,84 +0,0 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
// Decorator to mark routes as public (skip authentication)
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// Decorator to mark routes as optional authentication
export const IS_OPTIONAL_AUTH_KEY = 'isOptionalAuth';
export const OptionalAuth = () => SetMetadata(IS_OPTIONAL_AUTH_KEY, true);
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
constructor(private reflector: Reflector) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
// Check if route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
// Check if route has optional authentication
const isOptionalAuth = this.reflector.getAllAndOverride<boolean>(
IS_OPTIONAL_AUTH_KEY,
[context.getHandler(), context.getClass()],
);
if (isOptionalAuth) {
// Try to authenticate but don't fail if no token
try {
return super.canActivate(context);
} catch {
return true; // Allow request to proceed without authentication
}
}
// Default behavior: require authentication
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any, context: ExecutionContext) {
// Check if route has optional authentication
const isOptionalAuth = this.reflector.getAllAndOverride<boolean>(
IS_OPTIONAL_AUTH_KEY,
[context.getHandler(), context.getClass()],
);
if (err || !user) {
if (isOptionalAuth) {
return null; // No user, but that's okay for optional auth
}
throw err || new UnauthorizedException('Authentication required');
}
return user;
}
}
@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {
constructor() {
super({
accessType: 'offline',
prompt: 'consent',
});
}
}

View file

@ -1,33 +0,0 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy';
import { DatabaseModule } from '../database/database.module';
@Module({
imports: [
DatabaseModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN', '7d'),
issuer: 'seo-image-renamer',
audience: 'seo-image-renamer-users',
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, GoogleStrategy, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View file

@ -1,206 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { UserRepository } from '../database/repositories/user.repository';
import { Plan } from '@prisma/client';
describe('AuthService', () => {
let service: AuthService;
let userRepository: jest.Mocked<UserRepository>;
let jwtService: jest.Mocked<JwtService>;
let configService: jest.Mocked<ConfigService>;
const mockUser = {
id: 'user-123',
email: 'test@example.com',
plan: Plan.BASIC,
quotaRemaining: 50,
quotaResetDate: new Date(),
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: UserRepository,
useValue: {
findByEmail: jest.fn(),
findByGoogleUid: jest.fn(),
createWithOAuth: jest.fn(),
linkGoogleAccount: jest.fn(),
updateLastLogin: jest.fn(),
},
},
{
provide: JwtService,
useValue: {
sign: jest.fn(),
verify: jest.fn(),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
service = module.get<AuthService>(AuthService);
userRepository = module.get(UserRepository);
jwtService = module.get(JwtService);
configService = module.get(ConfigService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('validateGoogleUser', () => {
const googleProfile = {
id: 'google-123',
emails: [{ value: 'test@example.com', verified: true }],
displayName: 'Test User',
photos: [{ value: 'https://example.com/photo.jpg' }],
};
it('should return existing user if found by Google UID', async () => {
userRepository.findByGoogleUid.mockResolvedValue(mockUser);
const result = await service.validateGoogleUser(googleProfile);
expect(result).toEqual(mockUser);
expect(userRepository.findByGoogleUid).toHaveBeenCalledWith('google-123');
});
it('should return existing user if found by email and link Google account', async () => {
userRepository.findByGoogleUid.mockResolvedValue(null);
userRepository.findByEmail.mockResolvedValue(mockUser);
userRepository.linkGoogleAccount.mockResolvedValue(mockUser);
const result = await service.validateGoogleUser(googleProfile);
expect(result).toEqual(mockUser);
expect(userRepository.linkGoogleAccount).toHaveBeenCalledWith('user-123', 'google-123');
});
it('should create new user if not found', async () => {
userRepository.findByGoogleUid.mockResolvedValue(null);
userRepository.findByEmail.mockResolvedValue(null);
userRepository.createWithOAuth.mockResolvedValue(mockUser);
const result = await service.validateGoogleUser(googleProfile);
expect(result).toEqual(mockUser);
expect(userRepository.createWithOAuth).toHaveBeenCalledWith({
googleUid: 'google-123',
email: 'test@example.com',
emailHash: expect.any(String),
plan: Plan.BASIC,
quotaRemaining: 50,
quotaResetDate: expect.any(Date),
isActive: true,
});
});
it('should throw error if no email provided', async () => {
const profileWithoutEmail = {
...googleProfile,
emails: [],
};
await expect(service.validateGoogleUser(profileWithoutEmail)).rejects.toThrow(
'No email provided by Google'
);
});
});
describe('generateJwtToken', () => {
it('should generate JWT token with user payload', async () => {
const token = 'jwt-token-123';
jwtService.sign.mockReturnValue(token);
const result = await service.generateJwtToken(mockUser);
expect(result).toBe(token);
expect(jwtService.sign).toHaveBeenCalledWith({
sub: mockUser.id,
email: mockUser.email,
plan: mockUser.plan,
});
});
});
describe('verifyJwtToken', () => {
it('should verify and return JWT payload', async () => {
const payload = { sub: 'user-123', email: 'test@example.com' };
jwtService.verify.mockReturnValue(payload);
const result = await service.verifyJwtToken('jwt-token');
expect(result).toEqual(payload);
expect(jwtService.verify).toHaveBeenCalledWith('jwt-token');
});
it('should throw error for invalid token', async () => {
jwtService.verify.mockImplementation(() => {
throw new Error('Invalid token');
});
await expect(service.verifyJwtToken('invalid-token')).rejects.toThrow(
'Invalid token'
);
});
});
describe('validateUser', () => {
it('should return user if found and active', async () => {
userRepository.findById.mockResolvedValue(mockUser);
const result = await service.validateUser('user-123');
expect(result).toEqual(mockUser);
});
it('should return null if user not found', async () => {
userRepository.findById.mockResolvedValue(null);
const result = await service.validateUser('user-123');
expect(result).toBeNull();
});
it('should return null if user is inactive', async () => {
const inactiveUser = { ...mockUser, isActive: false };
userRepository.findById.mockResolvedValue(inactiveUser);
const result = await service.validateUser('user-123');
expect(result).toBeNull();
});
});
describe('hashEmail', () => {
it('should hash email consistently', () => {
const email = 'test@example.com';
const hash1 = service.hashEmail(email);
const hash2 = service.hashEmail(email);
expect(hash1).toBe(hash2);
expect(hash1).toHaveLength(64); // SHA-256 produces 64 character hex string
});
it('should produce different hashes for different emails', () => {
const hash1 = service.hashEmail('test1@example.com');
const hash2 = service.hashEmail('test2@example.com');
expect(hash1).not.toBe(hash2);
});
});
});

View file

@ -1,187 +0,0 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { User, Plan } from '@prisma/client';
import { createHash } from 'crypto';
import { UserRepository } from '../database/repositories/user.repository';
import { LoginResponseDto, AuthUserDto } from './dto/auth.dto';
import { calculateQuotaResetDate, getQuotaLimitForPlan } from '../users/users.entity';
export interface GoogleUserData {
googleUid: string;
email: string;
displayName?: string;
}
export interface JwtPayload {
sub: string; // User ID
email: string;
iat?: number;
exp?: number;
iss?: string;
aud?: string;
}
@Injectable()
export class AuthService {
constructor(
private readonly userRepository: UserRepository,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
/**
* Validate and find/create user from Google OAuth data
*/
async validateGoogleUser(googleUserData: GoogleUserData): Promise<User> {
const { googleUid, email, displayName } = googleUserData;
// First, try to find user by Google UID
let user = await this.userRepository.findByGoogleUid(googleUid);
if (user) {
// User exists, update last login and return
return await this.userRepository.updateLastLogin(user.id);
}
// Check if user exists with this email but no Google UID (existing account)
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser && !existingUser.googleUid) {
// Link Google account to existing user
return await this.userRepository.linkGoogleAccount(existingUser.id, googleUid);
}
if (existingUser && existingUser.googleUid && existingUser.googleUid !== googleUid) {
throw new ConflictException('Email already associated with different Google account');
}
// Create new user account
return await this.createUserFromGoogle(googleUserData);
}
/**
* Create new user from Google OAuth data
*/
private async createUserFromGoogle(googleUserData: GoogleUserData): Promise<User> {
const { googleUid, email, displayName } = googleUserData;
// Hash the email for privacy (SHA-256)
const emailHash = this.hashEmail(email);
// Create user with Basic plan and 50 quota as per requirements
const userData = {
googleUid,
email,
emailHash,
plan: Plan.BASIC,
quotaRemaining: getQuotaLimitForPlan(Plan.BASIC),
quotaResetDate: calculateQuotaResetDate(),
isActive: true,
};
return await this.userRepository.createWithOAuth(userData);
}
/**
* Validate user by ID (for JWT strategy)
*/
async validateUserById(userId: string): Promise<User | null> {
return await this.userRepository.findById(userId);
}
/**
* Generate JWT token for user
*/
async generateTokens(user: User): Promise<LoginResponseDto> {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
};
const accessToken = await this.jwtService.signAsync(payload);
const expiresIn = this.getTokenExpirationSeconds();
const authUser: AuthUserDto = {
id: user.id,
email: user.email,
displayName: user.email.split('@')[0], // Use email prefix as display name
plan: user.plan,
quotaRemaining: user.quotaRemaining,
};
return {
accessToken,
tokenType: 'Bearer',
expiresIn,
user: authUser,
};
}
/**
* Get user profile information
*/
async getProfile(userId: string): Promise<User> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
/**
* Hash email using SHA-256 for privacy
*/
private hashEmail(email: string): string {
return createHash('sha256').update(email.toLowerCase().trim()).digest('hex');
}
/**
* Get token expiration time in seconds
*/
private getTokenExpirationSeconds(): number {
const expiresIn = this.configService.get<string>('JWT_EXPIRES_IN', '7d');
// Convert duration string to seconds
if (expiresIn.endsWith('d')) {
return parseInt(expiresIn.replace('d', '')) * 24 * 60 * 60;
} else if (expiresIn.endsWith('h')) {
return parseInt(expiresIn.replace('h', '')) * 60 * 60;
} else if (expiresIn.endsWith('m')) {
return parseInt(expiresIn.replace('m', '')) * 60;
} else if (expiresIn.endsWith('s')) {
return parseInt(expiresIn.replace('s', ''));
}
// Default to seconds if no unit specified
return parseInt(expiresIn) || 604800; // 7 days default
}
/**
* Validate JWT token and return payload
*/
async validateToken(token: string): Promise<JwtPayload | null> {
try {
return await this.jwtService.verifyAsync(token);
} catch {
return null;
}
}
/**
* Invalidate user session (for logout)
* Note: With stateless JWT, we rely on token expiration
* In production, consider maintaining a blacklist
*/
async logout(userId: string): Promise<{ message: string }> {
// Update user's last activity
await this.userRepository.updateLastActivity(userId);
return { message: 'Successfully logged out' };
}
}

View file

@ -1,137 +0,0 @@
import { IsString, IsEmail, IsOptional, IsUUID } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class GoogleOAuthCallbackDto {
@ApiProperty({
description: 'Authorization code from Google OAuth',
example: 'auth_code_from_google'
})
@IsString()
code: string;
@ApiPropertyOptional({
description: 'OAuth state parameter for CSRF protection',
example: 'random_state_string'
})
@IsOptional()
@IsString()
state?: string;
}
export class AuthUserDto {
@ApiProperty({
description: 'User unique identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
id: string;
@ApiProperty({
description: 'User email address',
example: 'user@example.com'
})
@IsEmail()
email: string;
@ApiPropertyOptional({
description: 'User display name from Google',
example: 'John Doe'
})
@IsOptional()
@IsString()
displayName?: string;
@ApiProperty({
description: 'User subscription plan',
example: 'BASIC'
})
@IsString()
plan: string;
@ApiProperty({
description: 'Remaining quota for current period',
example: 50
})
quotaRemaining: number;
}
export class LoginResponseDto {
@ApiProperty({
description: 'JWT access token',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
@IsString()
accessToken: string;
@ApiProperty({
description: 'Token type',
example: 'Bearer'
})
@IsString()
tokenType: string;
@ApiProperty({
description: 'Token expiration time in seconds',
example: 604800
})
expiresIn: number;
@ApiProperty({
description: 'User information',
type: () => AuthUserDto
})
user: AuthUserDto;
}
export class LogoutResponseDto {
@ApiProperty({
description: 'Logout success message',
example: 'Successfully logged out'
})
@IsString()
message: string;
}
export class AuthProfileDto {
@ApiProperty({
description: 'User unique identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
id: string;
@ApiProperty({
description: 'User email address',
example: 'user@example.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'User subscription plan',
example: 'BASIC'
})
@IsString()
plan: string;
@ApiProperty({
description: 'Remaining quota for current period',
example: 50
})
quotaRemaining: number;
@ApiProperty({
description: 'Date when quota resets'
})
quotaResetDate: Date;
@ApiProperty({
description: 'Whether the user account is active'
})
isActive: boolean;
@ApiProperty({
description: 'User creation timestamp'
})
createdAt: Date;
}

View file

@ -1,68 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { AuthService } from './auth.service';
export interface GoogleProfile {
id: string;
displayName: string;
name: {
familyName: string;
givenName: string;
};
emails: Array<{
value: string;
verified: boolean;
}>;
photos: Array<{
value: string;
}>;
provider: string;
_raw: string;
_json: any;
}
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
super({
clientID: configService.get<string>('GOOGLE_CLIENT_ID'),
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET'),
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'], // Only request email and profile scopes as per requirements
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: GoogleProfile,
done: VerifyCallback,
): Promise<void> {
try {
// Extract user information from Google profile
const { id, displayName, emails } = profile;
if (!emails || emails.length === 0) {
return done(new Error('No email found in Google profile'), null);
}
const email = emails[0].value;
// Find or create user through auth service
const user = await this.authService.validateGoogleUser({
googleUid: id,
email,
displayName,
});
return done(null, user);
} catch (error) {
return done(error, null);
}
}
}

View file

@ -1,56 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { AuthService } from './auth.service';
export interface JwtPayload {
sub: string; // User ID
email: string;
iat: number; // Issued at
exp: number; // Expires at
iss: string; // Issuer
aud: string; // Audience
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
issuer: 'seo-image-renamer',
audience: 'seo-image-renamer-users',
});
}
async validate(payload: JwtPayload) {
try {
// Verify the user still exists and is active
const user = await this.authService.validateUserById(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
}
if (!user.isActive) {
throw new UnauthorizedException('User account is inactive');
}
// Return user object that will be attached to request
return {
id: user.id,
email: user.email,
plan: user.plan,
quotaRemaining: user.quotaRemaining,
isActive: user.isActive,
};
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
}

View file

@ -1,227 +0,0 @@
import {
IsString,
IsEnum,
IsInt,
IsOptional,
IsUUID,
IsObject,
Min,
IsDate
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BatchStatus } from '@prisma/client';
import { Type } from 'class-transformer';
export class CreateBatchDto {
@ApiProperty({
description: 'ID of the user creating the batch',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
userId: string;
@ApiPropertyOptional({
description: 'Total number of images in this batch',
example: 10,
minimum: 0
})
@IsOptional()
@IsInt()
@Min(0)
totalImages?: number;
@ApiPropertyOptional({
description: 'Additional metadata for the batch processing',
example: {
aiModel: 'gpt-4-vision',
processingOptions: { includeColors: true, includeTags: true }
}
})
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
export class UpdateBatchDto {
@ApiPropertyOptional({
description: 'Batch processing status',
enum: BatchStatus
})
@IsOptional()
@IsEnum(BatchStatus)
status?: BatchStatus;
@ApiPropertyOptional({
description: 'Total number of images in this batch',
minimum: 0
})
@IsOptional()
@IsInt()
@Min(0)
totalImages?: number;
@ApiPropertyOptional({
description: 'Number of processed images',
minimum: 0
})
@IsOptional()
@IsInt()
@Min(0)
processedImages?: number;
@ApiPropertyOptional({
description: 'Number of failed images',
minimum: 0
})
@IsOptional()
@IsInt()
@Min(0)
failedImages?: number;
@ApiPropertyOptional({
description: 'Additional metadata for the batch processing'
})
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
export class BatchResponseDto {
@ApiProperty({
description: 'Unique batch identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
id: string;
@ApiProperty({
description: 'ID of the user who owns this batch',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
userId: string;
@ApiProperty({
description: 'Current batch processing status',
enum: BatchStatus
})
@IsEnum(BatchStatus)
status: BatchStatus;
@ApiProperty({
description: 'Total number of images in this batch',
example: 10
})
@IsInt()
@Min(0)
totalImages: number;
@ApiProperty({
description: 'Number of processed images',
example: 8
})
@IsInt()
@Min(0)
processedImages: number;
@ApiProperty({
description: 'Number of failed images',
example: 1
})
@IsInt()
@Min(0)
failedImages: number;
@ApiPropertyOptional({
description: 'Additional metadata for the batch processing'
})
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
@ApiProperty({
description: 'Batch creation timestamp'
})
@IsDate()
createdAt: Date;
@ApiProperty({
description: 'Batch last update timestamp'
})
@IsDate()
updatedAt: Date;
@ApiPropertyOptional({
description: 'Batch completion timestamp'
})
@IsOptional()
@IsDate()
completedAt?: Date;
}
export class BatchStatsDto {
@ApiProperty({
description: 'Processing progress percentage',
example: 80
})
@IsInt()
@Min(0)
progressPercentage: number;
@ApiProperty({
description: 'Number of pending images',
example: 1
})
@IsInt()
@Min(0)
pendingImages: number;
@ApiProperty({
description: 'Average processing time per image in seconds',
example: 5.2
})
@Type(() => Number)
averageProcessingTime: number;
@ApiProperty({
description: 'Estimated time remaining in seconds',
example: 30
})
@Type(() => Number)
estimatedTimeRemaining: number;
}
export class BatchSummaryDto {
@ApiProperty({
description: 'Batch details'
})
batch: BatchResponseDto;
@ApiProperty({
description: 'Processing statistics'
})
stats: BatchStatsDto;
@ApiProperty({
description: 'Recent images from this batch (limited to 5)'
})
recentImages: Array<{
id: string;
originalName: string;
proposedName?: string;
status: string;
}>;
}
// Helper function to calculate progress percentage
export function calculateProgressPercentage(processedImages: number, totalImages: number): number {
if (totalImages === 0) return 0;
return Math.round((processedImages / totalImages) * 100);
}
// Helper function to determine if batch is complete
export function isBatchComplete(batch: { status: BatchStatus; processedImages: number; failedImages: number; totalImages: number }): boolean {
return batch.status === BatchStatus.COMPLETED ||
batch.status === BatchStatus.FAILED ||
(batch.processedImages + batch.failedImages) >= batch.totalImages;
}

View file

@ -1,275 +0,0 @@
import {
Controller,
Post,
Get,
Param,
Body,
UploadedFiles,
UseInterceptors,
UseGuards,
Request,
HttpStatus,
BadRequestException,
PayloadTooLargeException,
ForbiddenException,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiResponse, ApiConsumes, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/auth.guard';
import { BatchesService } from './batches.service';
import { CreateBatchDto, BatchUploadResponseDto } from './dto/create-batch.dto';
import { BatchStatusResponseDto, BatchListResponseDto } from './dto/batch-status.dto';
@ApiTags('batches')
@Controller('api/batch')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class BatchesController {
constructor(private readonly batchesService: BatchesService) {}
@Post()
@UseInterceptors(FilesInterceptor('files', 1000)) // Max 1000 files per batch
@ApiOperation({
summary: 'Upload batch of images for processing',
description: 'Uploads multiple images and starts batch processing with AI analysis and SEO filename generation'
})
@ApiConsumes('multipart/form-data')
@ApiResponse({
status: HttpStatus.OK,
description: 'Batch created successfully',
type: BatchUploadResponseDto,
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Invalid files or missing data',
})
@ApiResponse({
status: HttpStatus.PAYLOAD_TOO_LARGE,
description: 'File size or count exceeds limits',
})
@ApiResponse({
status: HttpStatus.FORBIDDEN,
description: 'Insufficient quota remaining',
})
async uploadBatch(
@UploadedFiles() files: Express.Multer.File[],
@Body() createBatchDto: CreateBatchDto,
@Request() req: any,
): Promise<BatchUploadResponseDto> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
// Validate files are provided
if (!files || files.length === 0) {
throw new BadRequestException('No files provided');
}
// Check file count limits
if (files.length > 1000) {
throw new PayloadTooLargeException('Maximum 1000 files per batch');
}
// Process the batch upload
const result = await this.batchesService.createBatch(userId, files, createBatchDto);
return result;
} catch (error) {
if (error instanceof BadRequestException ||
error instanceof PayloadTooLargeException ||
error instanceof ForbiddenException) {
throw error;
}
throw new BadRequestException('Failed to process batch upload');
}
}
@Get(':batchId/status')
@ApiOperation({
summary: 'Get batch processing status',
description: 'Returns current status and progress of batch processing'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Batch status retrieved successfully',
type: BatchStatusResponseDto,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Batch not found',
})
@ApiResponse({
status: HttpStatus.FORBIDDEN,
description: 'Not authorized to access this batch',
})
async getBatchStatus(
@Param('batchId') batchId: string,
@Request() req: any,
): Promise<BatchStatusResponseDto> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
const status = await this.batchesService.getBatchStatus(batchId, userId);
return status;
} catch (error) {
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
throw error;
}
throw new BadRequestException('Failed to get batch status');
}
}
@Get()
@ApiOperation({
summary: 'List user batches',
description: 'Returns list of all batches for the authenticated user'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Batches retrieved successfully',
type: [BatchListResponseDto],
})
async getUserBatches(
@Request() req: any,
): Promise<BatchListResponseDto[]> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
const batches = await this.batchesService.getUserBatches(userId);
return batches;
} catch (error) {
throw new BadRequestException('Failed to get user batches');
}
}
@Post(':batchId/cancel')
@ApiOperation({
summary: 'Cancel batch processing',
description: 'Cancels ongoing batch processing'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Batch cancelled successfully',
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Batch not found',
})
@ApiResponse({
status: HttpStatus.FORBIDDEN,
description: 'Not authorized to cancel this batch',
})
async cancelBatch(
@Param('batchId') batchId: string,
@Request() req: any,
): Promise<{ message: string }> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
await this.batchesService.cancelBatch(batchId, userId);
return { message: 'Batch cancelled successfully' };
} catch (error) {
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
throw error;
}
throw new BadRequestException('Failed to cancel batch');
}
}
@Post(':batchId/retry')
@ApiOperation({
summary: 'Retry failed batch processing',
description: 'Retries processing for failed images in a batch'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Batch retry started successfully',
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Batch not found',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Batch is not in a retryable state',
})
async retryBatch(
@Param('batchId') batchId: string,
@Request() req: any,
): Promise<{ message: string; retry_count: number }> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
const retryCount = await this.batchesService.retryBatch(batchId, userId);
return {
message: 'Batch retry started successfully',
retry_count: retryCount
};
} catch (error) {
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
throw error;
}
throw new BadRequestException('Failed to retry batch');
}
}
@Get(':batchId/download')
@ApiOperation({
summary: 'Download processed batch as ZIP',
description: 'Returns a ZIP file containing all processed images with new filenames'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'ZIP file download started',
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Batch not found',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Batch processing not completed',
})
async downloadBatch(
@Param('batchId') batchId: string,
@Request() req: any,
): Promise<{ download_url: string; expires_at: string }> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
const downloadInfo = await this.batchesService.generateBatchDownload(batchId, userId);
return downloadInfo;
} catch (error) {
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
throw error;
}
throw new BadRequestException('Failed to generate batch download');
}
}
}

View file

@ -1,22 +0,0 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { StorageModule } from '../storage/storage.module';
import { UploadModule } from '../upload/upload.module';
import { QueueModule } from '../queue/queue.module';
import { WebSocketModule } from '../websocket/websocket.module';
import { BatchesController } from './batches.controller';
import { BatchesService } from './batches.service';
@Module({
imports: [
DatabaseModule,
StorageModule,
UploadModule,
QueueModule,
WebSocketModule,
],
controllers: [BatchesController],
providers: [BatchesService],
exports: [BatchesService],
})
export class BatchesModule {}

View file

@ -1,515 +0,0 @@
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { BatchStatus, ImageStatus, Plan } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../database/prisma.service';
import { UploadService } from '../upload/upload.service';
import { QueueService } from '../queue/queue.service';
import { ProgressGateway } from '../websocket/progress.gateway';
import { CreateBatchDto, BatchUploadResponseDto } from './dto/create-batch.dto';
import { BatchStatusResponseDto, BatchListResponseDto } from './dto/batch-status.dto';
import { calculateProgressPercentage } from '../batches/batch.entity';
@Injectable()
export class BatchesService {
private readonly logger = new Logger(BatchesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly uploadService: UploadService,
private readonly queueService: QueueService,
private readonly progressGateway: ProgressGateway,
) {}
/**
* Create a new batch and process uploaded files
*/
async createBatch(
userId: string,
files: Express.Multer.File[],
createBatchDto: CreateBatchDto
): Promise<BatchUploadResponseDto> {
try {
this.logger.log(`Creating batch for user: ${userId} with ${files.length} files`);
// Get user info and check quota
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { plan: true, quotaRemaining: true },
});
if (!user) {
throw new BadRequestException('User not found');
}
// Check quota
const quotaCheck = this.uploadService.checkUploadQuota(
files.length,
user.plan,
user.quotaRemaining
);
if (!quotaCheck.allowed) {
throw new ForbiddenException(
`Insufficient quota. Requested: ${files.length}, Remaining: ${user.quotaRemaining}`
);
}
// Create batch record
const batchId = uuidv4();
const batch = await this.prisma.batch.create({
data: {
id: batchId,
userId,
status: BatchStatus.PROCESSING,
totalImages: files.length,
processedImages: 0,
failedImages: 0,
metadata: {
keywords: createBatchDto.keywords || [],
uploadedAt: new Date().toISOString(),
},
},
});
// Process files
let acceptedCount = 0;
let skippedCount = 0;
const imageIds: string[] = [];
try {
const processedFiles = await this.uploadService.processMultipleFiles(
files,
batchId,
createBatchDto.keywords
);
// Create image records in database
for (const processedFile of processedFiles) {
try {
const imageId = uuidv4();
await this.prisma.image.create({
data: {
id: imageId,
batchId,
originalName: processedFile.originalName,
status: ImageStatus.PENDING,
fileSize: processedFile.uploadResult.size,
mimeType: processedFile.mimeType,
dimensions: {
width: processedFile.metadata.width,
height: processedFile.metadata.height,
format: processedFile.metadata.format,
},
s3Key: processedFile.uploadResult.key,
},
});
imageIds.push(imageId);
acceptedCount++;
} catch (error) {
this.logger.error(`Failed to create image record: ${processedFile.originalName}`, error.stack);
skippedCount++;
}
}
skippedCount += files.length - processedFiles.length;
} catch (error) {
this.logger.error(`Failed to process files for batch: ${batchId}`, error.stack);
skippedCount = files.length;
}
// Update batch with actual counts
await this.prisma.batch.update({
where: { id: batchId },
data: {
totalImages: acceptedCount,
},
});
// Update user quota
await this.prisma.user.update({
where: { id: userId },
data: {
quotaRemaining: user.quotaRemaining - acceptedCount,
},
});
// Queue batch processing if we have accepted files
if (acceptedCount > 0) {
await this.queueService.addBatchProcessingJob({
batchId,
userId,
imageIds,
keywords: createBatchDto.keywords,
});
}
// Estimate processing time (2-5 seconds per image)
const estimatedTime = acceptedCount * (3 + Math.random() * 2);
this.logger.log(`Batch created: ${batchId} - ${acceptedCount} accepted, ${skippedCount} skipped`);
return {
batch_id: batchId,
accepted_count: acceptedCount,
skipped_count: skippedCount,
status: 'PROCESSING',
estimated_time: Math.round(estimatedTime),
};
} catch (error) {
this.logger.error(`Failed to create batch for user: ${userId}`, error.stack);
throw error;
}
}
/**
* Get batch status and progress
*/
async getBatchStatus(batchId: string, userId: string): Promise<BatchStatusResponseDto> {
try {
const batch = await this.prisma.batch.findFirst({
where: {
id: batchId,
userId,
},
include: {
images: {
select: {
status: true,
originalName: true,
},
},
},
});
if (!batch) {
throw new NotFoundException('Batch not found');
}
// Calculate progress
const progress = calculateProgressPercentage(batch.processedImages, batch.totalImages);
// Find currently processing image
const processingImage = batch.images.find(img => img.status === ImageStatus.PROCESSING);
// Estimate remaining time based on average processing time
const remainingImages = batch.totalImages - batch.processedImages;
const estimatedRemaining = remainingImages * 3; // 3 seconds per image average
// Map status to API response format
let state: 'PROCESSING' | 'DONE' | 'ERROR';
switch (batch.status) {
case BatchStatus.PROCESSING:
state = 'PROCESSING';
break;
case BatchStatus.COMPLETED:
state = 'DONE';
break;
case BatchStatus.FAILED:
state = 'ERROR';
break;
}
return {
state,
progress,
processed_count: batch.processedImages,
total_count: batch.totalImages,
failed_count: batch.failedImages,
current_image: processingImage?.originalName,
estimated_remaining: state === 'PROCESSING' ? estimatedRemaining : undefined,
error_message: batch.status === BatchStatus.FAILED ? 'Processing failed' : undefined,
created_at: batch.createdAt.toISOString(),
completed_at: batch.completedAt?.toISOString(),
};
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Failed to get batch status: ${batchId}`, error.stack);
throw new BadRequestException('Failed to get batch status');
}
}
/**
* Get list of user's batches
*/
async getUserBatches(userId: string): Promise<BatchListResponseDto[]> {
try {
const batches = await this.prisma.batch.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 50, // Limit to last 50 batches
});
return batches.map(batch => ({
id: batch.id,
state: batch.status === BatchStatus.PROCESSING ? 'PROCESSING' :
batch.status === BatchStatus.COMPLETED ? 'DONE' : 'ERROR',
total_images: batch.totalImages,
processed_images: batch.processedImages,
failed_images: batch.failedImages,
progress: calculateProgressPercentage(batch.processedImages, batch.totalImages),
created_at: batch.createdAt.toISOString(),
completed_at: batch.completedAt?.toISOString(),
}));
} catch (error) {
this.logger.error(`Failed to get user batches: ${userId}`, error.stack);
throw new BadRequestException('Failed to get user batches');
}
}
/**
* Cancel ongoing batch processing
*/
async cancelBatch(batchId: string, userId: string): Promise<void> {
try {
const batch = await this.prisma.batch.findFirst({
where: {
id: batchId,
userId,
status: BatchStatus.PROCESSING,
},
});
if (!batch) {
throw new NotFoundException('Batch not found or not in processing state');
}
// Cancel queue jobs
await this.queueService.cancelJob(`batch-${batchId}`, 'batch-processing');
// Update batch status
await this.prisma.batch.update({
where: { id: batchId },
data: {
status: BatchStatus.FAILED,
completedAt: new Date(),
metadata: {
...(batch.metadata as object || {}),
cancelledAt: new Date().toISOString(),
cancelReason: 'User requested cancellation',
},
},
});
// Update pending images to failed
await this.prisma.image.updateMany({
where: {
batchId,
status: {
in: [ImageStatus.PENDING, ImageStatus.PROCESSING],
},
},
data: {
status: ImageStatus.FAILED,
processingError: 'Batch was cancelled',
},
});
// Broadcast cancellation
this.progressGateway.broadcastBatchError(batchId, 'Batch was cancelled');
this.logger.log(`Batch cancelled: ${batchId}`);
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Failed to cancel batch: ${batchId}`, error.stack);
throw new BadRequestException('Failed to cancel batch');
}
}
/**
* Retry failed batch processing
*/
async retryBatch(batchId: string, userId: string): Promise<number> {
try {
const batch = await this.prisma.batch.findFirst({
where: {
id: batchId,
userId,
},
include: {
images: {
where: { status: ImageStatus.FAILED },
select: { id: true },
},
},
});
if (!batch) {
throw new NotFoundException('Batch not found');
}
if (batch.status === BatchStatus.PROCESSING) {
throw new BadRequestException('Batch is currently processing');
}
if (batch.images.length === 0) {
throw new BadRequestException('No failed images to retry');
}
// Reset failed images to pending
await this.prisma.image.updateMany({
where: {
batchId,
status: ImageStatus.FAILED,
},
data: {
status: ImageStatus.PENDING,
processingError: null,
},
});
// Update batch status
await this.prisma.batch.update({
where: { id: batchId },
data: {
status: BatchStatus.PROCESSING,
completedAt: null,
failedImages: 0,
},
});
// Queue retry processing
await this.queueService.addBatchProcessingJob({
batchId,
userId,
imageIds: batch.images.map(img => img.id),
});
this.logger.log(`Batch retry started: ${batchId} with ${batch.images.length} images`);
return batch.images.length;
} catch (error) {
if (error instanceof NotFoundException || error instanceof BadRequestException) {
throw error;
}
this.logger.error(`Failed to retry batch: ${batchId}`, error.stack);
throw new BadRequestException('Failed to retry batch');
}
}
/**
* Generate download link for processed batch
*/
async generateBatchDownload(batchId: string, userId: string): Promise<{
download_url: string;
expires_at: string;
}> {
try {
const batch = await this.prisma.batch.findFirst({
where: {
id: batchId,
userId,
status: BatchStatus.COMPLETED,
},
include: {
images: {
where: { status: ImageStatus.COMPLETED },
select: { s3Key: true, finalName: true, proposedName: true, originalName: true },
},
},
});
if (!batch) {
throw new NotFoundException('Batch not found or not completed');
}
if (batch.images.length === 0) {
throw new BadRequestException('No processed images available for download');
}
// TODO: Implement actual ZIP generation and presigned URL creation
// This would typically:
// 1. Create a ZIP file containing all processed images
// 2. Upload ZIP to storage
// 3. Generate presigned download URL
// For now, return a mock response
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
return {
download_url: `https://storage.example.com/downloads/batch-${batchId}.zip?expires=${expiresAt.getTime()}`,
expires_at: expiresAt.toISOString(),
};
} catch (error) {
if (error instanceof NotFoundException || error instanceof BadRequestException) {
throw error;
}
this.logger.error(`Failed to generate batch download: ${batchId}`, error.stack);
throw new BadRequestException('Failed to generate batch download');
}
}
/**
* Update batch processing progress (called by queue processors)
*/
async updateBatchProgress(
batchId: string,
processedImages: number,
failedImages: number,
currentImageName?: string
): Promise<void> {
try {
const batch = await this.prisma.batch.findUnique({
where: { id: batchId },
});
if (!batch) {
return;
}
const isComplete = (processedImages + failedImages) >= batch.totalImages;
const newStatus = isComplete ?
(failedImages === batch.totalImages ? BatchStatus.FAILED : BatchStatus.COMPLETED) :
BatchStatus.PROCESSING;
// Update batch record
await this.prisma.batch.update({
where: { id: batchId },
data: {
processedImages,
failedImages,
status: newStatus,
completedAt: isComplete ? new Date() : null,
},
});
// Broadcast progress update
const progress = calculateProgressPercentage(processedImages, batch.totalImages);
this.progressGateway.broadcastBatchProgress(batchId, {
state: newStatus === BatchStatus.PROCESSING ? 'PROCESSING' :
newStatus === BatchStatus.COMPLETED ? 'DONE' : 'ERROR',
progress,
processedImages,
totalImages: batch.totalImages,
currentImage: currentImageName,
});
// Broadcast completion if done
if (isComplete) {
this.progressGateway.broadcastBatchCompleted(batchId, {
totalImages: batch.totalImages,
processedImages,
failedImages,
processingTime: Date.now() - batch.createdAt.getTime(),
});
}
} catch (error) {
this.logger.error(`Failed to update batch progress: ${batchId}`, error.stack);
}
}
}

View file

@ -1,142 +0,0 @@
import { IsEnum, IsInt, IsOptional, IsString, Min, Max } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BatchStatusResponseDto {
@ApiProperty({
description: 'Current batch processing state',
example: 'PROCESSING',
enum: ['PROCESSING', 'DONE', 'ERROR'],
})
@IsEnum(['PROCESSING', 'DONE', 'ERROR'])
state: 'PROCESSING' | 'DONE' | 'ERROR';
@ApiProperty({
description: 'Processing progress percentage',
example: 75,
minimum: 0,
maximum: 100,
})
@IsInt()
@Min(0)
@Max(100)
progress: number;
@ApiPropertyOptional({
description: 'Number of images currently processed',
example: 6,
minimum: 0,
})
@IsOptional()
@IsInt()
@Min(0)
processed_count?: number;
@ApiPropertyOptional({
description: 'Total number of images in the batch',
example: 8,
minimum: 0,
})
@IsOptional()
@IsInt()
@Min(0)
total_count?: number;
@ApiPropertyOptional({
description: 'Number of failed images',
example: 1,
minimum: 0,
})
@IsOptional()
@IsInt()
@Min(0)
failed_count?: number;
@ApiPropertyOptional({
description: 'Currently processing image name',
example: 'IMG_20240101_123456.jpg',
})
@IsOptional()
@IsString()
current_image?: string;
@ApiPropertyOptional({
description: 'Estimated time remaining in seconds',
example: 15,
minimum: 0,
})
@IsOptional()
@IsInt()
@Min(0)
estimated_remaining?: number;
@ApiPropertyOptional({
description: 'Error message if batch failed',
example: 'Processing timeout occurred',
})
@IsOptional()
@IsString()
error_message?: string;
@ApiProperty({
description: 'Batch creation timestamp',
example: '2024-01-01T12:00:00.000Z',
})
created_at: string;
@ApiPropertyOptional({
description: 'Batch completion timestamp',
example: '2024-01-01T12:05:30.000Z',
})
@IsOptional()
completed_at?: string;
}
export class BatchListResponseDto {
@ApiProperty({
description: 'Batch identifier',
example: '550e8400-e29b-41d4-a716-446655440000',
})
id: string;
@ApiProperty({
description: 'Batch processing state',
enum: ['PROCESSING', 'DONE', 'ERROR'],
})
state: 'PROCESSING' | 'DONE' | 'ERROR';
@ApiProperty({
description: 'Total number of images',
example: 10,
})
total_images: number;
@ApiProperty({
description: 'Number of processed images',
example: 8,
})
processed_images: number;
@ApiProperty({
description: 'Number of failed images',
example: 1,
})
failed_images: number;
@ApiProperty({
description: 'Processing progress percentage',
example: 90,
})
progress: number;
@ApiProperty({
description: 'Batch creation timestamp',
example: '2024-01-01T12:00:00.000Z',
})
created_at: string;
@ApiPropertyOptional({
description: 'Batch completion timestamp',
example: '2024-01-01T12:05:30.000Z',
})
completed_at?: string;
}

View file

@ -1,49 +0,0 @@
import { IsOptional, IsString, IsArray, ArrayMaxSize, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateBatchDto {
@ApiPropertyOptional({
description: 'Keywords to help with AI analysis and filename generation',
example: ['kitchen', 'modern', 'renovation'],
maxItems: 10,
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(10)
@MaxLength(50, { each: true })
keywords?: string[];
}
export class BatchUploadResponseDto {
@ApiProperty({
description: 'Unique batch identifier',
example: '550e8400-e29b-41d4-a716-446655440000',
})
batch_id: string;
@ApiProperty({
description: 'Number of files accepted for processing',
example: 8,
})
accepted_count: number;
@ApiProperty({
description: 'Number of files skipped (duplicates, invalid format, etc.)',
example: 2,
})
skipped_count: number;
@ApiProperty({
description: 'Initial processing status',
example: 'PROCESSING',
enum: ['PROCESSING'],
})
status: 'PROCESSING';
@ApiProperty({
description: 'Estimated processing time in seconds',
example: 45,
})
estimated_time: number;
}

View file

@ -1,89 +0,0 @@
import { Injectable, NestMiddleware, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
interface RateLimitStore {
[key: string]: {
count: number;
resetTime: number;
};
}
@Injectable()
export class RateLimitMiddleware implements NestMiddleware {
private store: RateLimitStore = {};
private readonly windowMs: number = 60 * 1000; // 1 minute
private readonly maxRequests: number = 10; // 10 requests per minute for auth endpoints
use(req: Request, res: Response, next: NextFunction): void {
const clientId = this.getClientId(req);
const now = Date.now();
// Clean up expired entries
this.cleanup(now);
// Get or create rate limit entry for this client
if (!this.store[clientId]) {
this.store[clientId] = {
count: 0,
resetTime: now + this.windowMs,
};
}
const clientData = this.store[clientId];
// Check if window has expired
if (now > clientData.resetTime) {
clientData.count = 0;
clientData.resetTime = now + this.windowMs;
}
// Check rate limit
if (clientData.count >= this.maxRequests) {
const remainingTime = Math.ceil((clientData.resetTime - now) / 1000);
res.setHeader('X-RateLimit-Limit', this.maxRequests);
res.setHeader('X-RateLimit-Remaining', 0);
res.setHeader('X-RateLimit-Reset', Math.ceil(clientData.resetTime / 1000));
res.setHeader('Retry-After', remainingTime);
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: `Too many requests. Try again in ${remainingTime} seconds.`,
error: 'Too Many Requests',
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
// Increment counter
clientData.count++;
// Set response headers
res.setHeader('X-RateLimit-Limit', this.maxRequests);
res.setHeader('X-RateLimit-Remaining', this.maxRequests - clientData.count);
res.setHeader('X-RateLimit-Reset', Math.ceil(clientData.resetTime / 1000));
next();
}
private getClientId(req: Request): string {
// Use forwarded IP if behind proxy, otherwise use connection IP
const forwarded = req.headers['x-forwarded-for'] as string;
const ip = forwarded ? forwarded.split(',')[0].trim() : req.connection.remoteAddress;
// Include user agent for additional uniqueness
const userAgent = req.headers['user-agent'] || 'unknown';
return `${ip}:${userAgent}`;
}
private cleanup(now: number): void {
// Remove expired entries to prevent memory leak
for (const [clientId, data] of Object.entries(this.store)) {
if (now > data.resetTime + this.windowMs) {
delete this.store[clientId];
}
}
}
}

View file

@ -1,102 +0,0 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class SecurityMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
// CSRF Protection for state-changing requests
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
this.applyCsrfProtection(req, res);
}
// Security Headers
this.setSecurityHeaders(res);
next();
}
private applyCsrfProtection(req: Request, res: Response): void {
// Skip CSRF for OAuth callbacks and API endpoints with JWT
const skipPaths = [
'/auth/google/callback',
'/auth/google',
];
if (skipPaths.some(path => req.path.includes(path))) {
return;
}
// For JWT-protected endpoints, the JWT itself provides CSRF protection
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return;
}
// For cookie-based requests, check for CSRF token
const csrfToken = req.headers['x-csrf-token'] as string;
const cookieToken = req.cookies?.['csrf-token'];
if (!csrfToken || csrfToken !== cookieToken) {
// Set CSRF token if not present
if (!cookieToken) {
const token = this.generateCsrfToken();
res.cookie('csrf-token', token, {
httpOnly: false, // Allow JS access for CSRF token
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 1000, // 1 hour
});
}
}
}
private setSecurityHeaders(res: Response): void {
// Content Security Policy
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://accounts.google.com; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://accounts.google.com; " +
"frame-src https://accounts.google.com; " +
"object-src 'none'; " +
"base-uri 'self';"
);
// X-Content-Type-Options
res.setHeader('X-Content-Type-Options', 'nosniff');
// X-Frame-Options
res.setHeader('X-Frame-Options', 'DENY');
// X-XSS-Protection
res.setHeader('X-XSS-Protection', '1; mode=block');
// Referrer Policy
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions Policy
res.setHeader(
'Permissions-Policy',
'geolocation=(), microphone=(), camera=(), fullscreen=(self)'
);
// Strict Transport Security (HTTPS only)
if (process.env.NODE_ENV === 'production') {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
}
}
private generateCsrfToken(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
}

View file

@ -1,27 +0,0 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaService } from './prisma.service';
import { UserRepository } from './repositories/user.repository';
import { BatchRepository } from './repositories/batch.repository';
import { ImageRepository } from './repositories/image.repository';
import { PaymentRepository } from './repositories/payment.repository';
@Global()
@Module({
imports: [ConfigModule],
providers: [
PrismaService,
UserRepository,
BatchRepository,
ImageRepository,
PaymentRepository,
],
exports: [
PrismaService,
UserRepository,
BatchRepository,
ImageRepository,
PaymentRepository,
],
})
export class DatabaseModule {}

View file

@ -1,99 +0,0 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
constructor(private configService: ConfigService) {
super({
datasources: {
db: {
url: configService.get<string>('DATABASE_URL'),
},
},
log: ['error', 'warn'],
errorFormat: 'colorless',
});
// Simplified logging approach
}
async onModuleInit() {
try {
await this.$connect();
this.logger.log('Successfully connected to database');
// Test the connection
await this.$queryRaw`SELECT 1`;
this.logger.log('Database connection test passed');
} catch (error) {
this.logger.error('Failed to connect to database:', error);
throw error;
}
}
async onModuleDestroy() {
try {
await this.$disconnect();
this.logger.log('Disconnected from database');
} catch (error) {
this.logger.error('Error during database disconnection:', error);
}
}
/**
* Clean shutdown method for graceful application termination
*/
async enableShutdownHooks() {
process.on('beforeExit', async () => {
await this.$disconnect();
});
}
/**
* Health check method to verify database connectivity
*/
async healthCheck(): Promise<boolean> {
try {
await this.$queryRaw`SELECT 1`;
return true;
} catch (error) {
this.logger.error('Database health check failed:', error);
return false;
}
}
/**
* Get database statistics
*/
async getDatabaseStats() {
try {
const [userCount, batchCount, imageCount, paymentCount] = await Promise.all([
this.user.count(),
this.batch.count(),
this.image.count(),
this.payment.count(),
]);
return {
users: userCount,
batches: batchCount,
images: imageCount,
payments: paymentCount,
timestamp: new Date(),
};
} catch (error) {
this.logger.error('Failed to get database stats:', error);
throw error;
}
}
/**
* Transaction helper method
*/
async transaction<T>(fn: (prisma: PrismaClient) => Promise<T>): Promise<T> {
return this.$transaction(fn);
}
}

View file

@ -1,349 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Batch, BatchStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import { CreateBatchDto, UpdateBatchDto } from '../../batches/batch.entity';
@Injectable()
export class BatchRepository {
private readonly logger = new Logger(BatchRepository.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new batch
*/
async create(data: CreateBatchDto): Promise<Batch> {
try {
return await this.prisma.batch.create({
data: {
...data,
status: BatchStatus.PROCESSING,
},
});
} catch (error) {
this.logger.error('Failed to create batch:', error);
throw error;
}
}
/**
* Find batch by ID
*/
async findById(id: string): Promise<Batch | null> {
try {
return await this.prisma.batch.findUnique({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to find batch by ID ${id}:`, error);
throw error;
}
}
/**
* Update batch
*/
async update(id: string, data: UpdateBatchDto): Promise<Batch> {
try {
const updateData: any = { ...data };
// Set completedAt if status is changing to DONE or ERROR
if (data.status && (data.status === BatchStatus.COMPLETED || data.status === BatchStatus.FAILED)) {
updateData.completedAt = new Date();
}
return await this.prisma.batch.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update batch ${id}:`, error);
throw error;
}
}
/**
* Delete batch
*/
async delete(id: string): Promise<Batch> {
try {
return await this.prisma.batch.delete({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to delete batch ${id}:`, error);
throw error;
}
}
/**
* Find batches with pagination
*/
async findMany(params: {
skip?: number;
take?: number;
where?: Prisma.BatchWhereInput;
orderBy?: Prisma.BatchOrderByWithRelationInput;
}): Promise<Batch[]> {
try {
return await this.prisma.batch.findMany({
skip: params.skip,
take: params.take,
where: params.where,
orderBy: params.orderBy,
});
} catch (error) {
this.logger.error('Failed to find batches:', error);
throw error;
}
}
/**
* Find batches by user ID
*/
async findByUserId(
userId: string,
params?: {
skip?: number;
take?: number;
status?: BatchStatus;
orderBy?: Prisma.BatchOrderByWithRelationInput;
}
): Promise<Batch[]> {
try {
return await this.prisma.batch.findMany({
where: {
userId,
...(params?.status && { status: params.status }),
},
skip: params?.skip,
take: params?.take,
orderBy: params?.orderBy || { createdAt: 'desc' },
});
} catch (error) {
this.logger.error(`Failed to find batches for user ${userId}:`, error);
throw error;
}
}
/**
* Count batches
*/
async count(where?: Prisma.BatchWhereInput): Promise<number> {
try {
return await this.prisma.batch.count({ where });
} catch (error) {
this.logger.error('Failed to count batches:', error);
throw error;
}
}
/**
* Find batch with images
*/
async findByIdWithImages(id: string): Promise<Batch & {
images: any[];
user: any;
_count: { images: number };
} | null> {
try {
return await this.prisma.batch.findUnique({
where: { id },
include: {
images: {
orderBy: { createdAt: 'asc' },
},
user: {
select: {
id: true,
email: true,
plan: true,
},
},
_count: {
select: { images: true },
},
},
});
} catch (error) {
this.logger.error(`Failed to find batch with images ${id}:`, error);
throw error;
}
}
/**
* Update batch progress
*/
async updateProgress(id: string, processedImages: number, failedImages: number): Promise<Batch> {
try {
const batch = await this.findById(id);
if (!batch) {
throw new Error(`Batch ${id} not found`);
}
// Determine if batch is complete
const totalProcessed = processedImages + failedImages;
const isComplete = totalProcessed >= batch.totalImages;
const updateData: any = {
processedImages,
failedImages,
};
if (isComplete) {
updateData.status = failedImages === batch.totalImages ? BatchStatus.FAILED : BatchStatus.COMPLETED;
updateData.completedAt = new Date();
}
return await this.prisma.batch.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update batch progress ${id}:`, error);
throw error;
}
}
/**
* Increment processed images count
*/
async incrementProcessedImages(id: string): Promise<Batch> {
try {
return await this.prisma.batch.update({
where: { id },
data: {
processedImages: { increment: 1 },
},
});
} catch (error) {
this.logger.error(`Failed to increment processed images for batch ${id}:`, error);
throw error;
}
}
/**
* Increment failed images count
*/
async incrementFailedImages(id: string): Promise<Batch> {
try {
return await this.prisma.batch.update({
where: { id },
data: {
failedImages: { increment: 1 },
},
});
} catch (error) {
this.logger.error(`Failed to increment failed images for batch ${id}:`, error);
throw error;
}
}
/**
* Find processing batches (for cleanup/monitoring)
*/
async findProcessingBatches(olderThanMinutes?: number): Promise<Batch[]> {
try {
const where: Prisma.BatchWhereInput = {
status: BatchStatus.PROCESSING,
};
if (olderThanMinutes) {
const cutoffTime = new Date();
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
where.createdAt = { lte: cutoffTime };
}
return await this.prisma.batch.findMany({
where,
orderBy: { createdAt: 'asc' },
});
} catch (error) {
this.logger.error('Failed to find processing batches:', error);
throw error;
}
}
/**
* Get batch statistics
*/
async getBatchStats(batchId: string): Promise<{
totalImages: number;
processedImages: number;
failedImages: number;
pendingImages: number;
progressPercentage: number;
averageProcessingTime?: number;
}> {
try {
const batch = await this.findByIdWithImages(batchId);
if (!batch) {
throw new Error(`Batch ${batchId} not found`);
}
const pendingImages = batch.totalImages - batch.processedImages - batch.failedImages;
const progressPercentage = Math.round(
((batch.processedImages + batch.failedImages) / batch.totalImages) * 100
);
// Calculate average processing time from completed images
const completedImages = batch.images.filter(img => img.processedAt);
let averageProcessingTime: number | undefined;
if (completedImages.length > 0) {
const totalProcessingTime = completedImages.reduce((sum, img) => {
const processingTime = img.processedAt.getTime() - img.createdAt.getTime();
return sum + processingTime;
}, 0);
averageProcessingTime = totalProcessingTime / completedImages.length / 1000; // Convert to seconds
}
return {
totalImages: batch.totalImages,
processedImages: batch.processedImages,
failedImages: batch.failedImages,
pendingImages,
progressPercentage,
averageProcessingTime,
};
} catch (error) {
this.logger.error(`Failed to get batch stats for ${batchId}:`, error);
throw error;
}
}
/**
* Get user batch statistics
*/
async getUserBatchStats(userId: string): Promise<{
totalBatches: number;
completedBatches: number;
processingBatches: number;
errorBatches: number;
totalImages: number;
}> {
try {
const [totalBatches, completedBatches, processingBatches, errorBatches, imageStats] = await Promise.all([
this.count({ userId }),
this.count({ userId, status: BatchStatus.COMPLETED }),
this.count({ userId, status: BatchStatus.PROCESSING }),
this.count({ userId, status: BatchStatus.FAILED }),
this.prisma.batch.aggregate({
where: { userId },
_sum: { totalImages: true },
}),
]);
return {
totalBatches,
completedBatches,
processingBatches,
errorBatches,
totalImages: imageStats._sum.totalImages || 0,
};
} catch (error) {
this.logger.error(`Failed to get user batch stats for ${userId}:`, error);
throw error;
}
}
}

View file

@ -1,457 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Image, ImageStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import { CreateImageDto, UpdateImageDto } from '../../images/image.entity';
@Injectable()
export class ImageRepository {
private readonly logger = new Logger(ImageRepository.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new image
*/
async create(data: CreateImageDto): Promise<Image> {
try {
return await this.prisma.image.create({
data: {
...data,
status: ImageStatus.PENDING,
} as any,
});
} catch (error) {
this.logger.error('Failed to create image:', error);
throw error;
}
}
/**
* Create multiple images in batch
*/
async createMany(images: CreateImageDto[]): Promise<{ count: number }> {
try {
const data = images.map(img => ({
...img,
status: ImageStatus.PENDING,
}));
return await this.prisma.image.createMany({
data: data as any,
skipDuplicates: true,
});
} catch (error) {
this.logger.error('Failed to create multiple images:', error);
throw error;
}
}
/**
* Find image by ID
*/
async findById(id: string): Promise<Image | null> {
try {
return await this.prisma.image.findUnique({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to find image by ID ${id}:`, error);
throw error;
}
}
/**
* Update image
*/
async update(id: string, data: UpdateImageDto): Promise<Image> {
try {
const updateData: any = { ...data };
// Set processedAt if status is changing to COMPLETED or FAILED
if (data.status && (data.status === ImageStatus.COMPLETED || data.status === ImageStatus.FAILED)) {
updateData.processedAt = new Date();
}
return await this.prisma.image.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update image ${id}:`, error);
throw error;
}
}
/**
* Delete image
*/
async delete(id: string): Promise<Image> {
try {
return await this.prisma.image.delete({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to delete image ${id}:`, error);
throw error;
}
}
/**
* Find images with pagination
*/
async findMany(params: {
skip?: number;
take?: number;
where?: Prisma.ImageWhereInput;
orderBy?: Prisma.ImageOrderByWithRelationInput;
}): Promise<Image[]> {
try {
return await this.prisma.image.findMany({
skip: params.skip,
take: params.take,
where: params.where,
orderBy: params.orderBy,
});
} catch (error) {
this.logger.error('Failed to find images:', error);
throw error;
}
}
/**
* Find images by batch ID
*/
async findByBatchId(
batchId: string,
params?: {
skip?: number;
take?: number;
status?: ImageStatus;
orderBy?: Prisma.ImageOrderByWithRelationInput;
}
): Promise<Image[]> {
try {
return await this.prisma.image.findMany({
where: {
batchId,
...(params?.status && { status: params.status }),
},
skip: params?.skip,
take: params?.take,
orderBy: params?.orderBy || { createdAt: 'asc' },
});
} catch (error) {
this.logger.error(`Failed to find images for batch ${batchId}:`, error);
throw error;
}
}
/**
* Count images
*/
async count(where?: Prisma.ImageWhereInput): Promise<number> {
try {
return await this.prisma.image.count({ where });
} catch (error) {
this.logger.error('Failed to count images:', error);
throw error;
}
}
/**
* Find image with batch info
*/
async findByIdWithBatch(id: string): Promise<Image & {
batch: any;
} | null> {
try {
return await this.prisma.image.findUnique({
where: { id },
include: {
batch: {
include: {
user: {
select: {
id: true,
email: true,
plan: true,
},
},
},
},
},
});
} catch (error) {
this.logger.error(`Failed to find image with batch ${id}:`, error);
throw error;
}
}
/**
* Update image status
*/
async updateStatus(id: string, status: ImageStatus, error?: string): Promise<Image> {
try {
const updateData: any = {
status,
...(error && { processingError: error }),
};
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
updateData.processedAt = new Date();
}
return await this.prisma.image.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update image status ${id}:`, error);
throw error;
}
}
/**
* Bulk update image statuses
*/
async bulkUpdateStatus(imageIds: string[], status: ImageStatus): Promise<{ count: number }> {
try {
const updateData: any = { status };
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
updateData.processedAt = new Date();
}
return await this.prisma.image.updateMany({
where: {
id: { in: imageIds },
},
data: updateData,
});
} catch (error) {
this.logger.error('Failed to bulk update image statuses:', error);
throw error;
}
}
/**
* Apply proposed names as final names
*/
async applyProposedNames(imageIds: string[]): Promise<{ count: number }> {
try {
// First, get all images with their proposed names
const images = await this.prisma.image.findMany({
where: {
id: { in: imageIds },
proposedName: { not: null },
},
select: { id: true, proposedName: true },
});
// Use transaction to update each image with its proposed name as final name
const results = await this.prisma.$transaction(
images.map(image =>
this.prisma.image.update({
where: { id: image.id },
data: { finalName: image.proposedName },
})
)
);
return { count: results.length };
} catch (error) {
this.logger.error('Failed to apply proposed names:', error);
throw error;
}
}
/**
* Find pending images for processing
*/
async findPendingImages(limit?: number): Promise<Image[]> {
try {
return await this.prisma.image.findMany({
where: {
status: ImageStatus.PENDING,
},
orderBy: { createdAt: 'asc' },
take: limit,
include: {
batch: {
include: {
user: {
select: {
id: true,
email: true,
plan: true,
},
},
},
},
},
});
} catch (error) {
this.logger.error('Failed to find pending images:', error);
throw error;
}
}
/**
* Find processing images (for cleanup/monitoring)
*/
async findProcessingImages(olderThanMinutes?: number): Promise<Image[]> {
try {
const where: Prisma.ImageWhereInput = {
status: ImageStatus.PROCESSING,
};
if (olderThanMinutes) {
const cutoffTime = new Date();
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
where.updatedAt = { lte: cutoffTime };
}
return await this.prisma.image.findMany({
where,
orderBy: { updatedAt: 'asc' },
});
} catch (error) {
this.logger.error('Failed to find processing images:', error);
throw error;
}
}
/**
* Get image processing statistics for a batch
*/
async getBatchImageStats(batchId: string): Promise<{
total: number;
pending: number;
processing: number;
completed: number;
failed: number;
}> {
try {
const [total, pending, processing, completed, failed] = await Promise.all([
this.count({ batchId }),
this.count({ batchId, status: ImageStatus.PENDING }),
this.count({ batchId, status: ImageStatus.PROCESSING }),
this.count({ batchId, status: ImageStatus.COMPLETED }),
this.count({ batchId, status: ImageStatus.FAILED }),
]);
return {
total,
pending,
processing,
completed,
failed,
};
} catch (error) {
this.logger.error(`Failed to get batch image stats for ${batchId}:`, error);
throw error;
}
}
/**
* Get user image processing statistics
*/
async getUserImageStats(userId: string): Promise<{
totalImages: number;
completedImages: number;
failedImages: number;
processingImages: number;
pendingImages: number;
}> {
try {
const [
totalImages,
completedImages,
failedImages,
processingImages,
pendingImages,
] = await Promise.all([
this.prisma.image.count({
where: {
batch: { userId },
},
}),
this.prisma.image.count({
where: {
batch: { userId },
status: ImageStatus.COMPLETED,
},
}),
this.prisma.image.count({
where: {
batch: { userId },
status: ImageStatus.FAILED,
},
}),
this.prisma.image.count({
where: {
batch: { userId },
status: ImageStatus.PROCESSING,
},
}),
this.prisma.image.count({
where: {
batch: { userId },
status: ImageStatus.PENDING,
},
}),
]);
return {
totalImages,
completedImages,
failedImages,
processingImages,
pendingImages,
};
} catch (error) {
this.logger.error(`Failed to get user image stats for ${userId}:`, error);
throw error;
}
}
/**
* Search images by original name
*/
async searchByOriginalName(
searchTerm: string,
userId?: string,
params?: { skip?: number; take?: number }
): Promise<Image[]> {
try {
const where: Prisma.ImageWhereInput = {
originalName: {
contains: searchTerm,
mode: 'insensitive',
},
...(userId && {
batch: { userId },
}),
};
return await this.prisma.image.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: params?.skip,
take: params?.take,
include: {
batch: {
select: {
id: true,
status: true,
createdAt: true,
},
},
},
});
} catch (error) {
this.logger.error('Failed to search images by original name:', error);
throw error;
}
}
}

View file

@ -1,437 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Payment, PaymentStatus, Plan, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import { CreatePaymentDto, UpdatePaymentDto } from '../../payments/payment.entity';
@Injectable()
export class PaymentRepository {
private readonly logger = new Logger(PaymentRepository.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new payment
*/
async create(data: CreatePaymentDto): Promise<Payment> {
try {
return await this.prisma.payment.create({
data: {
...data,
status: PaymentStatus.PENDING,
} as any,
});
} catch (error) {
this.logger.error('Failed to create payment:', error);
throw error;
}
}
/**
* Find payment by ID
*/
async findById(id: string): Promise<Payment | null> {
try {
return await this.prisma.payment.findUnique({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to find payment by ID ${id}:`, error);
throw error;
}
}
/**
* Find payment by Stripe Session ID
*/
async findByStripeSessionId(stripeSessionId: string): Promise<Payment | null> {
try {
return await this.prisma.payment.findUnique({
where: { stripeSessionId },
});
} catch (error) {
this.logger.error(`Failed to find payment by Stripe Session ID ${stripeSessionId}:`, error);
throw error;
}
}
/**
* Find payment by Stripe Payment ID
*/
async findByStripePaymentId(stripePaymentId: string): Promise<Payment | null> {
try {
return await this.prisma.payment.findUnique({
where: { stripePaymentId },
});
} catch (error) {
this.logger.error(`Failed to find payment by Stripe Payment ID ${stripePaymentId}:`, error);
throw error;
}
}
/**
* Update payment
*/
async update(id: string, data: UpdatePaymentDto): Promise<Payment> {
try {
const updateData: any = { ...data };
// Set paidAt if status is changing to COMPLETED
if (data.status === PaymentStatus.COMPLETED) {
updateData.paidAt = new Date();
}
return await this.prisma.payment.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update payment ${id}:`, error);
throw error;
}
}
/**
* Delete payment
*/
async delete(id: string): Promise<Payment> {
try {
return await this.prisma.payment.delete({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to delete payment ${id}:`, error);
throw error;
}
}
/**
* Find payments with pagination
*/
async findMany(params: {
skip?: number;
take?: number;
where?: Prisma.PaymentWhereInput;
orderBy?: Prisma.PaymentOrderByWithRelationInput;
}): Promise<Payment[]> {
try {
return await this.prisma.payment.findMany({
skip: params.skip,
take: params.take,
where: params.where,
orderBy: params.orderBy,
});
} catch (error) {
this.logger.error('Failed to find payments:', error);
throw error;
}
}
/**
* Find payments by user ID
*/
async findByUserId(
userId: string,
params?: {
skip?: number;
take?: number;
status?: PaymentStatus;
orderBy?: Prisma.PaymentOrderByWithRelationInput;
}
): Promise<Payment[]> {
try {
return await this.prisma.payment.findMany({
where: {
userId,
...(params?.status && { status: params.status }),
},
skip: params?.skip,
take: params?.take,
orderBy: params?.orderBy || { createdAt: 'desc' },
});
} catch (error) {
this.logger.error(`Failed to find payments for user ${userId}:`, error);
throw error;
}
}
/**
* Count payments
*/
async count(where?: Prisma.PaymentWhereInput): Promise<number> {
try {
return await this.prisma.payment.count({ where });
} catch (error) {
this.logger.error('Failed to count payments:', error);
throw error;
}
}
/**
* Find payment with user info
*/
async findByIdWithUser(id: string): Promise<Payment & {
user: any;
} | null> {
try {
return await this.prisma.payment.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
email: true,
plan: true,
quotaRemaining: true,
},
},
},
});
} catch (error) {
this.logger.error(`Failed to find payment with user ${id}:`, error);
throw error;
}
}
/**
* Update payment status
*/
async updateStatus(id: string, status: PaymentStatus, stripePaymentId?: string): Promise<Payment> {
try {
const updateData: any = { status };
if (stripePaymentId) {
updateData.stripePaymentId = stripePaymentId;
}
if (status === PaymentStatus.COMPLETED) {
updateData.paidAt = new Date();
}
return await this.prisma.payment.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update payment status ${id}:`, error);
throw error;
}
}
/**
* Find successful payments by user
*/
async findSuccessfulPaymentsByUserId(userId: string): Promise<Payment[]> {
try {
return await this.prisma.payment.findMany({
where: {
userId,
status: PaymentStatus.COMPLETED,
},
orderBy: { paidAt: 'desc' },
});
} catch (error) {
this.logger.error(`Failed to find successful payments for user ${userId}:`, error);
throw error;
}
}
/**
* Get user payment statistics
*/
async getUserPaymentStats(userId: string): Promise<{
totalPayments: number;
successfulPayments: number;
failedPayments: number;
totalAmountSpent: number;
lastPaymentDate?: Date;
averagePaymentAmount: number;
}> {
try {
const [
totalPayments,
successfulPayments,
failedPayments,
amountStats,
lastSuccessfulPayment,
] = await Promise.all([
this.count({ userId }),
this.count({ userId, status: PaymentStatus.COMPLETED }),
this.count({
userId,
status: { in: [PaymentStatus.FAILED, PaymentStatus.CANCELLED] }
}),
this.prisma.payment.aggregate({
where: {
userId,
status: PaymentStatus.COMPLETED
},
_sum: { amount: true },
_avg: { amount: true },
}),
this.prisma.payment.findFirst({
where: {
userId,
status: PaymentStatus.COMPLETED
},
orderBy: { paidAt: 'desc' },
select: { paidAt: true },
}),
]);
return {
totalPayments,
successfulPayments,
failedPayments,
totalAmountSpent: amountStats._sum.amount || 0,
lastPaymentDate: lastSuccessfulPayment?.paidAt || undefined,
averagePaymentAmount: Math.round(amountStats._avg.amount || 0),
};
} catch (error) {
this.logger.error(`Failed to get user payment stats for ${userId}:`, error);
throw error;
}
}
/**
* Find pending payments (for cleanup/monitoring)
*/
async findPendingPayments(olderThanMinutes?: number): Promise<Payment[]> {
try {
const where: Prisma.PaymentWhereInput = {
status: PaymentStatus.PENDING,
};
if (olderThanMinutes) {
const cutoffTime = new Date();
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
where.createdAt = { lte: cutoffTime };
}
return await this.prisma.payment.findMany({
where,
orderBy: { createdAt: 'asc' },
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
} catch (error) {
this.logger.error('Failed to find pending payments:', error);
throw error;
}
}
/**
* Get revenue statistics
*/
async getRevenueStats(params?: {
startDate?: Date;
endDate?: Date;
plan?: Plan;
}): Promise<{
totalRevenue: number;
totalPayments: number;
averagePaymentAmount: number;
revenueByPlan: Record<Plan, number>;
paymentsCount: Record<PaymentStatus, number>;
}> {
try {
const where: Prisma.PaymentWhereInput = {
status: PaymentStatus.COMPLETED,
...(params?.startDate && { createdAt: { gte: params.startDate } }),
...(params?.endDate && { createdAt: { lte: params.endDate } }),
...(params?.plan && { plan: params.plan }),
};
const [revenueStats, revenueByPlan, paymentStatusCounts] = await Promise.all([
this.prisma.payment.aggregate({
where,
_sum: { amount: true },
_count: true,
_avg: { amount: true },
}),
this.prisma.payment.groupBy({
by: ['plan'],
where,
_sum: { amount: true },
}),
this.prisma.payment.groupBy({
by: ['status'],
_count: true,
}),
]);
const revenueByPlanMap = Object.values(Plan).reduce((acc, plan) => {
acc[plan] = 0;
return acc;
}, {} as Record<Plan, number>);
revenueByPlan.forEach(item => {
revenueByPlanMap[item.plan] = item._sum.amount || 0;
});
const paymentsCountMap = Object.values(PaymentStatus).reduce((acc, status) => {
acc[status] = 0;
return acc;
}, {} as Record<PaymentStatus, number>);
paymentStatusCounts.forEach(item => {
paymentsCountMap[item.status] = item._count;
});
return {
totalRevenue: revenueStats._sum.amount || 0,
totalPayments: revenueStats._count,
averagePaymentAmount: Math.round(revenueStats._avg.amount || 0),
revenueByPlan: revenueByPlanMap,
paymentsCount: paymentsCountMap,
};
} catch (error) {
this.logger.error('Failed to get revenue stats:', error);
throw error;
}
}
/**
* Find payments by date range
*/
async findPaymentsByDateRange(
startDate: Date,
endDate: Date,
params?: {
userId?: string;
status?: PaymentStatus;
plan?: Plan;
}
): Promise<Payment[]> {
try {
return await this.prisma.payment.findMany({
where: {
createdAt: {
gte: startDate,
lte: endDate,
},
...(params?.userId && { userId: params.userId }),
...(params?.status && { status: params.status }),
...(params?.plan && { plan: params.plan }),
},
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
} catch (error) {
this.logger.error('Failed to find payments by date range:', error);
throw error;
}
}
}

View file

@ -1,425 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { User, Plan, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import { CreateUserDto, UpdateUserDto } from '../../users/users.entity';
@Injectable()
export class UserRepository {
private readonly logger = new Logger(UserRepository.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new user
*/
async create(data: CreateUserDto): Promise<User> {
try {
return await this.prisma.user.create({
data: {
...data,
plan: data.plan || Plan.BASIC,
quotaRemaining: data.quotaRemaining || this.getQuotaForPlan(data.plan || Plan.BASIC),
},
});
} catch (error) {
this.logger.error('Failed to create user:', error);
throw error;
}
}
/**
* Find user by ID
*/
async findById(id: string): Promise<User | null> {
try {
return await this.prisma.user.findUnique({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to find user by ID ${id}:`, error);
throw error;
}
}
/**
* Find user by email
*/
async findByEmail(email: string): Promise<User | null> {
try {
return await this.prisma.user.findUnique({
where: { email },
});
} catch (error) {
this.logger.error(`Failed to find user by email ${email}:`, error);
throw error;
}
}
/**
* Find user by Google UID
*/
async findByGoogleUid(googleUid: string): Promise<User | null> {
try {
return await this.prisma.user.findUnique({
where: { googleUid },
});
} catch (error) {
this.logger.error(`Failed to find user by Google UID ${googleUid}:`, error);
throw error;
}
}
/**
* Find user by email hash
*/
async findByEmailHash(emailHash: string): Promise<User | null> {
try {
return await this.prisma.user.findUnique({
where: { emailHash },
});
} catch (error) {
this.logger.error(`Failed to find user by email hash:`, error);
throw error;
}
}
/**
* Update user
*/
async update(id: string, data: UpdateUserDto): Promise<User> {
try {
return await this.prisma.user.update({
where: { id },
data,
});
} catch (error) {
this.logger.error(`Failed to update user ${id}:`, error);
throw error;
}
}
/**
* Delete user
*/
async delete(id: string): Promise<User> {
try {
return await this.prisma.user.delete({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to delete user ${id}:`, error);
throw error;
}
}
/**
* Find users with pagination
*/
async findMany(params: {
skip?: number;
take?: number;
where?: Prisma.UserWhereInput;
orderBy?: Prisma.UserOrderByWithRelationInput;
}): Promise<User[]> {
try {
return await this.prisma.user.findMany({
skip: params.skip,
take: params.take,
where: params.where,
orderBy: params.orderBy,
});
} catch (error) {
this.logger.error('Failed to find users:', error);
throw error;
}
}
/**
* Count users
*/
async count(where?: Prisma.UserWhereInput): Promise<number> {
try {
return await this.prisma.user.count({ where });
} catch (error) {
this.logger.error('Failed to count users:', error);
throw error;
}
}
/**
* Update user quota
*/
async updateQuota(id: string, quotaRemaining: number): Promise<User> {
try {
return await this.prisma.user.update({
where: { id },
data: { quotaRemaining },
});
} catch (error) {
this.logger.error(`Failed to update quota for user ${id}:`, error);
throw error;
}
}
/**
* Deduct quota from user
*/
async deductQuota(id: string, amount: number): Promise<User> {
try {
return await this.prisma.user.update({
where: { id },
data: {
quotaRemaining: {
decrement: amount,
},
},
});
} catch (error) {
this.logger.error(`Failed to deduct quota for user ${id}:`, error);
throw error;
}
}
/**
* Reset user quota (monthly reset)
*/
async resetQuota(id: string): Promise<User> {
try {
const user = await this.findById(id);
if (!user) {
throw new Error(`User ${id} not found`);
}
const newQuota = this.getQuotaForPlan(user.plan);
const nextResetDate = this.calculateNextResetDate();
return await this.prisma.user.update({
where: { id },
data: {
quotaRemaining: newQuota,
quotaResetDate: nextResetDate,
},
});
} catch (error) {
this.logger.error(`Failed to reset quota for user ${id}:`, error);
throw error;
}
}
/**
* Upgrade user plan
*/
async upgradePlan(id: string, newPlan: Plan): Promise<User> {
try {
const newQuota = this.getQuotaForPlan(newPlan);
return await this.prisma.user.update({
where: { id },
data: {
plan: newPlan,
quotaRemaining: newQuota,
quotaResetDate: this.calculateNextResetDate(),
},
});
} catch (error) {
this.logger.error(`Failed to upgrade plan for user ${id}:`, error);
throw error;
}
}
/**
* Find users with expired quotas
*/
async findUsersWithExpiredQuotas(): Promise<User[]> {
try {
return await this.prisma.user.findMany({
where: {
quotaResetDate: {
lte: new Date(),
},
isActive: true,
},
});
} catch (error) {
this.logger.error('Failed to find users with expired quotas:', error);
throw error;
}
}
/**
* Get user with related data
*/
async findByIdWithRelations(id: string): Promise<User & {
batches: any[];
payments: any[];
_count: {
batches: number;
payments: number;
};
} | null> {
try {
return await this.prisma.user.findUnique({
where: { id },
include: {
batches: {
orderBy: { createdAt: 'desc' },
take: 5,
},
payments: {
orderBy: { createdAt: 'desc' },
take: 5,
},
_count: {
select: {
batches: true,
payments: true,
},
},
},
});
} catch (error) {
this.logger.error(`Failed to find user with relations ${id}:`, error);
throw error;
}
}
/**
* Helper: Get quota for plan
*/
private getQuotaForPlan(plan: Plan): number {
switch (plan) {
case Plan.BASIC:
return 50;
case Plan.PRO:
return 500;
case Plan.MAX:
return 1000;
default:
return 50;
}
}
/**
* Link Google account to existing user
*/
async linkGoogleAccount(userId: string, googleUid: string): Promise<User> {
try {
return await this.prisma.user.update({
where: { id: userId },
data: { googleUid },
});
} catch (error) {
this.logger.error(`Failed to link Google account for user ${userId}:`, error);
throw error;
}
}
/**
* Update user's last login timestamp
*/
async updateLastLogin(userId: string): Promise<User> {
try {
return await this.prisma.user.update({
where: { id: userId },
data: { updatedAt: new Date() },
});
} catch (error) {
this.logger.error(`Failed to update last login for user ${userId}:`, error);
throw error;
}
}
/**
* Update user's last activity timestamp
*/
async updateLastActivity(userId: string): Promise<User> {
try {
return await this.prisma.user.update({
where: { id: userId },
data: { updatedAt: new Date() },
});
} catch (error) {
this.logger.error(`Failed to update last activity for user ${userId}:`, error);
throw error;
}
}
/**
* Create user with OAuth data
*/
async createWithOAuth(data: {
googleUid: string;
email: string;
emailHash: string;
plan: Plan;
quotaRemaining: number;
quotaResetDate: Date;
isActive: boolean;
}): Promise<User> {
try {
return await this.prisma.user.create({
data,
});
} catch (error) {
this.logger.error('Failed to create user with OAuth data:', error);
throw error;
}
}
/**
* Helper: Calculate next quota reset date (first day of next month)
*/
private calculateNextResetDate(): Date {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() + 1, 1);
}
/**
* Update user plan
*/
async updatePlan(userId: string, plan: Plan): Promise<User> {
try {
const newQuota = this.getQuotaForPlan(plan);
return await this.prisma.user.update({
where: { id: userId },
data: {
plan,
quotaRemaining: newQuota,
quotaResetDate: this.calculateNextResetDate(),
},
});
} catch (error) {
this.logger.error(`Failed to update plan for user ${userId}:`, error);
throw error;
}
}
/**
* Find user by Stripe customer ID
*/
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
try {
return await this.prisma.user.findUnique({
where: { stripeCustomerId },
});
} catch (error) {
this.logger.error(`Failed to find user by Stripe customer ID ${stripeCustomerId}:`, error);
throw error;
}
}
/**
* Update Stripe customer ID
*/
async updateStripeCustomerId(userId: string, stripeCustomerId: string): Promise<User> {
try {
return await this.prisma.user.update({
where: { id: userId },
data: { stripeCustomerId },
});
} catch (error) {
this.logger.error(`Failed to update Stripe customer ID for user ${userId}:`, error);
throw error;
}
}
}

View file

@ -1,225 +0,0 @@
import {
Controller,
Get,
Post,
Param,
UseGuards,
Request,
Response,
HttpStatus,
HttpException,
Logger,
Body,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { Response as ExpressResponse } from 'express';
import { JwtAuthGuard } from '../auth/auth.guard';
import { DownloadService } from './download.service';
import { CreateDownloadDto } from './dto/create-download.dto';
@ApiTags('downloads')
@Controller('downloads')
export class DownloadController {
private readonly logger = new Logger(DownloadController.name);
constructor(private readonly downloadService: DownloadService) {}
@Post('create')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create download for batch' })
@ApiResponse({ status: 201, description: 'Download created successfully' })
async createDownload(
@Request() req: any,
@Body() createDownloadDto: CreateDownloadDto,
) {
try {
const userId = req.user.id;
const download = await this.downloadService.createDownload(
userId,
createDownloadDto.batchId,
);
return {
downloadId: download.id,
downloadUrl: download.downloadUrl,
expiresAt: download.expiresAt,
totalSize: download.totalSize,
fileCount: download.fileCount,
};
} catch (error) {
this.logger.error('Failed to create download:', error);
throw new HttpException(
'Failed to create download',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get(':downloadId/status')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get download status' })
@ApiResponse({ status: 200, description: 'Download status retrieved successfully' })
async getDownloadStatus(
@Request() req: any,
@Param('downloadId') downloadId: string,
) {
try {
const userId = req.user.id;
const status = await this.downloadService.getDownloadStatus(userId, downloadId);
return status;
} catch (error) {
this.logger.error('Failed to get download status:', error);
throw new HttpException(
'Failed to get download status',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get(':downloadId')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Download ZIP file' })
@ApiResponse({ status: 200, description: 'ZIP file download started' })
async downloadZip(
@Request() req: any,
@Param('downloadId') downloadId: string,
@Response() res: ExpressResponse,
) {
try {
const userId = req.user.id;
// Validate download access
const download = await this.downloadService.validateDownloadAccess(userId, downloadId);
// Get download stream
const { stream, filename, size } = await this.downloadService.getDownloadStream(downloadId);
// Set response headers
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', size.toString());
res.setHeader('Cache-Control', 'no-cache');
// Track download
await this.downloadService.trackDownload(downloadId);
// Pipe the stream to response
stream.pipe(res);
this.logger.log(`Download started: ${downloadId} for user ${userId}`);
} catch (error) {
this.logger.error('Failed to download ZIP:', error);
throw new HttpException(
'Failed to download ZIP file',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('user/history')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get user download history' })
@ApiResponse({ status: 200, description: 'Download history retrieved successfully' })
async getDownloadHistory(@Request() req: any) {
try {
const userId = req.user.id;
const history = await this.downloadService.getDownloadHistory(userId);
return { downloads: history };
} catch (error) {
this.logger.error('Failed to get download history:', error);
throw new HttpException(
'Failed to get download history',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Post(':downloadId/regenerate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Regenerate expired download' })
@ApiResponse({ status: 201, description: 'Download regenerated successfully' })
async regenerateDownload(
@Request() req: any,
@Param('downloadId') downloadId: string,
) {
try {
const userId = req.user.id;
const newDownload = await this.downloadService.regenerateDownload(userId, downloadId);
return {
downloadId: newDownload.id,
downloadUrl: newDownload.downloadUrl,
expiresAt: newDownload.expiresAt,
};
} catch (error) {
this.logger.error('Failed to regenerate download:', error);
throw new HttpException(
'Failed to regenerate download',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('batch/:batchId/preview')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Preview batch contents before download' })
@ApiResponse({ status: 200, description: 'Batch preview retrieved successfully' })
async previewBatch(
@Request() req: any,
@Param('batchId') batchId: string,
) {
try {
const userId = req.user.id;
const preview = await this.downloadService.previewBatch(userId, batchId);
return preview;
} catch (error) {
this.logger.error('Failed to preview batch:', error);
throw new HttpException(
'Failed to preview batch',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get(':downloadId/direct')
@ApiOperation({ summary: 'Direct download with token (no auth required)' })
@ApiResponse({ status: 200, description: 'Direct download started' })
async directDownload(
@Param('downloadId') downloadId: string,
@Response() res: ExpressResponse,
) {
try {
// Validate download token and expiry
const download = await this.downloadService.validateDirectDownload(downloadId);
// Get download stream
const { stream, filename, size } = await this.downloadService.getDownloadStream(downloadId);
// Set response headers
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', size.toString());
res.setHeader('Cache-Control', 'no-cache');
// Track download
await this.downloadService.trackDownload(downloadId);
// Pipe the stream to response
stream.pipe(res);
this.logger.log(`Direct download started: ${downloadId}`);
} catch (error) {
this.logger.error('Failed to direct download:', error);
throw new HttpException(
'Download link expired or invalid',
HttpStatus.NOT_FOUND,
);
}
}
}

View file

@ -1,27 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DownloadController } from './download.controller';
import { DownloadService } from './download.service';
import { ZipService } from './services/zip.service';
import { ExifService } from './services/exif.service';
import { StorageModule } from '../storage/storage.module';
import { DatabaseModule } from '../database/database.module';
@Module({
imports: [
ConfigModule,
StorageModule,
DatabaseModule,
],
controllers: [DownloadController],
providers: [
DownloadService,
ZipService,
ExifService,
],
exports: [
DownloadService,
ZipService,
],
})
export class DownloadModule {}

View file

@ -1,491 +0,0 @@
import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Readable } from 'stream';
import { ZipService } from './services/zip.service';
import { ExifService } from './services/exif.service';
import { BatchRepository } from '../database/repositories/batch.repository';
import { ImageRepository } from '../database/repositories/image.repository';
import { StorageService } from '../storage/storage.service';
import { PrismaService } from '../database/prisma.service';
import { v4 as uuidv4 } from 'uuid';
export interface DownloadInfo {
id: string;
downloadUrl: string;
expiresAt: Date;
totalSize: number;
fileCount: number;
}
export interface DownloadStream {
stream: Readable;
filename: string;
size: number;
}
@Injectable()
export class DownloadService {
private readonly logger = new Logger(DownloadService.name);
constructor(
private readonly configService: ConfigService,
private readonly zipService: ZipService,
private readonly exifService: ExifService,
private readonly batchRepository: BatchRepository,
private readonly imageRepository: ImageRepository,
private readonly storageService: StorageService,
private readonly prisma: PrismaService,
) {}
/**
* Create download for batch
*/
async createDownload(userId: string, batchId: string): Promise<DownloadInfo> {
try {
// Validate batch ownership and completion
const batch = await this.batchRepository.findById(batchId);
if (!batch) {
throw new NotFoundException('Batch not found');
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied to this batch');
}
if (batch.status !== 'COMPLETED') {
throw new Error('Batch is not completed yet');
}
// Get batch images
const images = await this.imageRepository.findByBatchId(batchId);
if (images.length === 0) {
throw new Error('No images found in batch');
}
// Create download record
const downloadId = uuidv4();
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hour expiry
// Calculate total size
let totalSize = 0;
for (const image of images) {
if (image.processedImageUrl) {
try {
const size = await this.storageService.getFileSize(image.processedImageUrl);
totalSize += size;
} catch (error) {
this.logger.warn(`Failed to get size for ${image.processedImageUrl}`);
}
}
}
// Store download info in database
const download = await this.prisma.download.create({
data: {
id: downloadId,
userId,
batchId,
zipPath: `${downloadId}.zip`,
fileSize: totalSize,
status: 'READY',
totalSize,
fileCount: images.length,
expiresAt,
downloadUrl: this.generateDownloadUrl(downloadId),
} as any,
});
this.logger.log(`Download created: ${downloadId} for batch ${batchId}`);
return {
id: download.id,
downloadUrl: download.downloadUrl,
expiresAt: download.expiresAt,
totalSize: download.totalSize,
fileCount: download.fileCount,
};
} catch (error) {
this.logger.error(`Failed to create download for batch ${batchId}:`, error);
throw error;
}
}
/**
* Get download status
*/
async getDownloadStatus(userId: string, downloadId: string) {
try {
const download = await this.prisma.download.findUnique({
where: { id: downloadId },
});
if (!download) {
throw new NotFoundException('Download not found');
}
if (download.userId !== userId) {
throw new ForbiddenException('Access denied to this download');
}
return {
id: download.id,
status: download.status,
batchId: download.batchId,
batchName: download.batchId,
totalSize: download.totalSize,
fileCount: download.fileCount,
downloadUrl: download.downloadUrl,
expiresAt: download.expiresAt,
createdAt: download.createdAt,
isExpired: new Date() > download.expiresAt,
};
} catch (error) {
this.logger.error(`Failed to get download status ${downloadId}:`, error);
throw error;
}
}
/**
* Validate download access
*/
async validateDownloadAccess(userId: string, downloadId: string) {
try {
const download = await this.prisma.download.findUnique({
where: { id: downloadId },
});
if (!download) {
throw new NotFoundException('Download not found');
}
if (download.userId !== userId) {
throw new ForbiddenException('Access denied to this download');
}
if (new Date() > download.expiresAt) {
throw new Error('Download link has expired');
}
if (download.status !== 'READY') {
throw new Error('Download is not ready');
}
return download;
} catch (error) {
this.logger.error(`Failed to validate download access ${downloadId}:`, error);
throw error;
}
}
/**
* Validate direct download (without auth)
*/
async validateDirectDownload(downloadId: string) {
try {
const download = await this.prisma.download.findUnique({
where: { id: downloadId },
});
if (!download) {
throw new NotFoundException('Download not found');
}
if (new Date() > download.expiresAt) {
throw new Error('Download link has expired');
}
if (download.status !== 'READY') {
throw new Error('Download is not ready');
}
return download;
} catch (error) {
this.logger.error(`Failed to validate direct download ${downloadId}:`, error);
throw error;
}
}
/**
* Get download stream
*/
async getDownloadStream(downloadId: string): Promise<DownloadStream> {
try {
const download = await this.prisma.download.findUnique({
where: { id: downloadId },
});
if (!download) {
throw new NotFoundException('Download not found');
}
// Get batch images
const images = await this.imageRepository.findByBatchId(download.batchId);
// Prepare files for ZIP
const files: Array<{
name: string;
path: string;
originalPath?: string;
}> = [];
for (const image of images) {
if (image.processedImageUrl) {
files.push({
name: image.generatedFilename || image.originalName,
path: image.processedImageUrl,
originalPath: image.originalImageUrl,
});
}
}
// Create ZIP stream with EXIF preservation
const zipStream = await this.zipService.createZipStream(files, {
preserveExif: true,
compressionLevel: 0, // Store only for faster downloads
});
const filename = `images-${downloadId.slice(0, 8)}.zip`;
return {
stream: zipStream,
filename,
size: download.totalSize,
};
} catch (error) {
this.logger.error(`Failed to get download stream ${downloadId}:`, error);
throw error;
}
}
/**
* Track download
*/
async trackDownload(downloadId: string): Promise<void> {
try {
await this.prisma.download.update({
where: { id: downloadId },
data: {
updatedAt: new Date(),
},
});
this.logger.log(`Download tracked: ${downloadId}`);
} catch (error) {
this.logger.error(`Failed to track download ${downloadId}:`, error);
// Don't throw error for tracking failures
}
}
/**
* Get download history for user
*/
async getDownloadHistory(userId: string, limit: number = 20) {
try {
const downloads = await this.prisma.download.findMany({
where: { userId },
orderBy: {
createdAt: 'desc',
},
take: limit,
});
return downloads.map(download => ({
id: download.id,
batchId: download.batchId,
batchName: download.batchId, // Use batchId as name for now
status: download.status,
totalSize: download.totalSize,
fileCount: download.fileCount,
createdAt: download.createdAt,
expiresAt: download.expiresAt,
isExpired: new Date() > download.expiresAt,
}));
} catch (error) {
this.logger.error(`Failed to get download history for user ${userId}:`, error);
throw error;
}
}
/**
* Regenerate expired download
*/
async regenerateDownload(userId: string, oldDownloadId: string): Promise<DownloadInfo> {
try {
const oldDownload = await this.prisma.download.findUnique({
where: { id: oldDownloadId },
});
if (!oldDownload) {
throw new NotFoundException('Download not found');
}
if (oldDownload.userId !== userId) {
throw new ForbiddenException('Access denied to this download');
}
// Create new download for the same batch
return await this.createDownload(userId, oldDownload.batchId);
} catch (error) {
this.logger.error(`Failed to regenerate download ${oldDownloadId}:`, error);
throw error;
}
}
/**
* Preview batch contents
*/
async previewBatch(userId: string, batchId: string) {
try {
// Validate batch ownership
const batch = await this.batchRepository.findById(batchId);
if (!batch) {
throw new NotFoundException('Batch not found');
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied to this batch');
}
// Get batch images
const images = await this.imageRepository.findByBatchId(batchId);
let totalSize = 0;
const fileList = [];
for (const image of images) {
let fileSize = 0;
if (image.processedImageUrl) {
try {
fileSize = await this.storageService.getFileSize(image.processedImageUrl);
totalSize += fileSize;
} catch (error) {
this.logger.warn(`Failed to get size for ${image.processedImageUrl}`);
}
}
fileList.push({
originalName: image.originalName,
newName: image.generatedFilename || image.originalName,
size: fileSize,
status: image.status,
hasChanges: image.generatedFilename !== image.originalName,
});
}
return {
batchId,
batchName: batch.name,
batchStatus: batch.status,
totalFiles: images.length,
totalSize,
files: fileList,
};
} catch (error) {
this.logger.error(`Failed to preview batch ${batchId}:`, error);
throw error;
}
}
/**
* Clean up expired downloads
*/
async cleanupExpiredDownloads(): Promise<number> {
try {
const expiredDownloads = await this.prisma.download.findMany({
where: {
expiresAt: {
lt: new Date(),
},
status: 'READY',
},
});
// Mark as expired
const result = await this.prisma.download.updateMany({
where: {
id: {
in: expiredDownloads.map(d => d.id),
},
},
data: {
status: 'EXPIRED',
},
});
this.logger.log(`Cleaned up ${result.count} expired downloads`);
return result.count;
} catch (error) {
this.logger.error('Failed to cleanup expired downloads:', error);
throw error;
}
}
/**
* Generate download URL
*/
private generateDownloadUrl(downloadId: string): string {
const baseUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
return `${baseUrl}/api/downloads/${downloadId}/direct`;
}
/**
* Get download analytics
*/
async getDownloadAnalytics(startDate?: Date, endDate?: Date) {
try {
const whereClause: any = {};
if (startDate && endDate) {
whereClause.createdAt = {
gte: startDate,
lte: endDate,
};
}
const [
totalDownloads,
totalFiles,
totalSize,
downloadsPerDay,
] = await Promise.all([
this.prisma.download.count({ where: whereClause }),
this.prisma.download.aggregate({
where: whereClause,
_sum: {
fileCount: true,
},
}),
this.prisma.download.aggregate({
where: whereClause,
_sum: {
totalSize: true,
},
}),
this.prisma.download.groupBy({
by: ['createdAt'],
where: whereClause,
_count: {
id: true,
},
}),
]);
return {
totalDownloads,
totalFiles: totalFiles._sum.fileCount || 0,
totalSize: totalSize._sum.totalSize || 0,
downloadsPerDay: downloadsPerDay.map(item => ({
date: item.createdAt,
count: item._count.id,
})),
};
} catch (error) {
this.logger.error('Failed to get download analytics:', error);
throw error;
}
}
}

View file

@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID, IsNotEmpty } from 'class-validator';
export class CreateDownloadDto {
@ApiProperty({
description: 'The batch ID to create download for',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@IsUUID()
@IsNotEmpty()
batchId: string;
}

View file

@ -1,311 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Readable, Transform } from 'stream';
import * as sharp from 'sharp';
import { StorageService } from '../../storage/storage.service';
@Injectable()
export class ExifService {
private readonly logger = new Logger(ExifService.name);
constructor(private readonly storageService: StorageService) {}
/**
* Preserve EXIF data from original image to processed image
*/
async preserveExifData(processedStream: Readable, originalImagePath: string): Promise<Readable> {
try {
// Get original image buffer to extract EXIF
const originalBuffer = await this.storageService.getFileBuffer(originalImagePath);
// Extract EXIF data from original
const originalMetadata = await sharp(originalBuffer).metadata();
if (!originalMetadata.exif && !originalMetadata.icc && !originalMetadata.iptc) {
this.logger.debug('No EXIF data found in original image');
return processedStream;
}
// Create transform stream to add EXIF data
const exifTransform = new Transform({
transform(chunk, encoding, callback) {
this.push(chunk);
callback();
},
});
// Convert stream to buffer, add EXIF, and return as stream
const processedChunks: Buffer[] = [];
processedStream.on('data', (chunk) => {
processedChunks.push(chunk);
});
processedStream.on('end', async () => {
try {
const processedBuffer = Buffer.concat(processedChunks);
// Apply EXIF data to processed image
const imageWithExif = await this.addExifToImage(
processedBuffer,
originalMetadata,
);
exifTransform.end(imageWithExif);
} catch (error) {
this.logger.error('Failed to add EXIF data:', error);
// Fallback to original processed image
exifTransform.end(Buffer.concat(processedChunks));
}
});
processedStream.on('error', (error) => {
this.logger.error('Error in processed stream:', error);
exifTransform.destroy(error);
});
return exifTransform;
} catch (error) {
this.logger.error('Failed to preserve EXIF data:', error);
// Return original stream if EXIF preservation fails
return processedStream;
}
}
/**
* Add EXIF data to image buffer
*/
private async addExifToImage(
imageBuffer: Buffer,
originalMetadata: sharp.Metadata,
): Promise<Buffer> {
try {
let sharpInstance = sharp(imageBuffer);
// Preserve important metadata
const options: sharp.JpegOptions | sharp.PngOptions = {};
// For JPEG images
if (originalMetadata.format === 'jpeg') {
const jpegOptions: sharp.JpegOptions = {
quality: 95, // High quality to preserve image
progressive: true,
};
// Add EXIF data if available
if (originalMetadata.exif) {
sharpInstance = sharpInstance.withMetadata();
}
return await sharpInstance.jpeg(jpegOptions).toBuffer();
}
// For PNG images
if (originalMetadata.format === 'png') {
const pngOptions: sharp.PngOptions = {
compressionLevel: 6,
progressive: true,
};
return await sharpInstance.png(pngOptions).toBuffer();
}
// For WebP images
if (originalMetadata.format === 'webp') {
return await sharpInstance
.webp({
quality: 95,
lossless: false,
})
.toBuffer();
}
// For other formats, return as-is
return imageBuffer;
} catch (error) {
this.logger.error('Failed to add EXIF to image:', error);
throw error;
}
}
/**
* Extract EXIF data from image
*/
async extractExifData(imagePath: string): Promise<{
exif?: any;
iptc?: any;
icc?: any;
xmp?: any;
}> {
try {
const imageBuffer = await this.storageService.getFileBuffer(imagePath);
const metadata = await sharp(imageBuffer).metadata();
return {
exif: metadata.exif,
iptc: metadata.iptc,
icc: metadata.icc,
xmp: metadata.xmp,
};
} catch (error) {
this.logger.error('Failed to extract EXIF data:', error);
throw error;
}
}
/**
* Get image metadata
*/
async getImageMetadata(imagePath: string): Promise<{
width?: number;
height?: number;
format?: string;
size?: number;
hasExif: boolean;
cameraMake?: string;
cameraModel?: string;
dateTime?: string;
gps?: {
latitude?: number;
longitude?: number;
};
}> {
try {
const imageBuffer = await this.storageService.getFileBuffer(imagePath);
const metadata = await sharp(imageBuffer).metadata();
// Parse EXIF data for common fields
let cameraMake: string | undefined;
let cameraModel: string | undefined;
let dateTime: string | undefined;
let gps: { latitude?: number; longitude?: number } | undefined;
if (metadata.exif) {
try {
// Parse EXIF buffer (this is a simplified example)
// In a real implementation, you might want to use a library like 'exif-parser'
const exifData = this.parseExifData(metadata.exif);
cameraMake = exifData.make;
cameraModel = exifData.model;
dateTime = exifData.dateTime;
gps = exifData.gps;
} catch (error) {
this.logger.warn('Failed to parse EXIF data:', error);
}
}
return {
width: metadata.width,
height: metadata.height,
format: metadata.format,
size: metadata.size,
hasExif: !!metadata.exif,
cameraMake,
cameraModel,
dateTime,
gps,
};
} catch (error) {
this.logger.error('Failed to get image metadata:', error);
throw error;
}
}
/**
* Remove EXIF data from image (for privacy)
*/
async removeExifData(imagePath: string): Promise<Buffer> {
try {
const imageBuffer = await this.storageService.getFileBuffer(imagePath);
return await sharp(imageBuffer)
.jpeg({ quality: 95 }) // This removes metadata by default
.toBuffer();
} catch (error) {
this.logger.error('Failed to remove EXIF data:', error);
throw error;
}
}
/**
* Copy EXIF data from one image to another
*/
async copyExifData(sourceImagePath: string, targetImageBuffer: Buffer): Promise<Buffer> {
try {
const sourceBuffer = await this.storageService.getFileBuffer(sourceImagePath);
const sourceMetadata = await sharp(sourceBuffer).metadata();
if (!sourceMetadata.exif) {
this.logger.debug('No EXIF data to copy');
return targetImageBuffer;
}
// Apply metadata to target image
return await this.addExifToImage(targetImageBuffer, sourceMetadata);
} catch (error) {
this.logger.error('Failed to copy EXIF data:', error);
throw error;
}
}
/**
* Validate image has EXIF data
*/
async hasExifData(imagePath: string): Promise<boolean> {
try {
const imageBuffer = await this.storageService.getFileBuffer(imagePath);
const metadata = await sharp(imageBuffer).metadata();
return !!(metadata.exif || metadata.iptc || metadata.xmp);
} catch (error) {
this.logger.error('Failed to check EXIF data:', error);
return false;
}
}
/**
* Parse EXIF data (simplified)
*/
private parseExifData(exifBuffer: Buffer): {
make?: string;
model?: string;
dateTime?: string;
gps?: { latitude?: number; longitude?: number };
} {
// This is a simplified EXIF parser
// In production, you should use a proper EXIF parsing library
try {
// For now, return empty object
// TODO: Implement proper EXIF parsing or use a library like 'exif-parser'
return {};
} catch (error) {
this.logger.warn('Failed to parse EXIF buffer:', error);
return {};
}
}
/**
* Get optimal image format for web delivery
*/
getOptimalFormat(originalFormat: string, hasTransparency: boolean = false): string {
// WebP for modern browsers (but this service focuses on download, so keep original format)
if (hasTransparency && originalFormat === 'png') {
return 'png';
}
if (originalFormat === 'gif') {
return 'gif';
}
// Default to JPEG for photos
return 'jpeg';
}
/**
* Estimate EXIF processing time
*/
estimateProcessingTime(fileSize: number): number {
// Rough estimate: 1MB takes about 100ms to process EXIF
const sizeInMB = fileSize / (1024 * 1024);
return Math.max(100, sizeInMB * 100); // Minimum 100ms
}
}

View file

@ -1,329 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Readable, PassThrough } from 'stream';
import * as archiver from 'archiver';
import { StorageService } from '../../storage/storage.service';
import { ExifService } from './exif.service';
export interface ZipOptions {
preserveExif?: boolean;
compressionLevel?: number;
password?: string;
}
export interface ZipFile {
name: string;
path: string;
originalPath?: string;
}
@Injectable()
export class ZipService {
private readonly logger = new Logger(ZipService.name);
constructor(
private readonly storageService: StorageService,
private readonly exifService: ExifService,
) {}
/**
* Create ZIP stream from files
*/
async createZipStream(files: ZipFile[], options: ZipOptions = {}): Promise<Readable> {
try {
const archive = archiver('zip', {
zlib: {
level: options.compressionLevel || 0, // 0 = store only, 9 = best compression
},
});
const outputStream = new PassThrough();
// Handle archive events
archive.on('error', (err) => {
this.logger.error('Archive error:', err);
outputStream.destroy(err);
});
archive.on('warning', (err) => {
if (err.code === 'ENOENT') {
this.logger.warn('Archive warning:', err);
} else {
this.logger.error('Archive warning:', err);
outputStream.destroy(err);
}
});
// Pipe archive to output stream
archive.pipe(outputStream);
// Add files to archive
for (const file of files) {
try {
await this.addFileToArchive(archive, file, options);
} catch (error) {
this.logger.error(`Failed to add file ${file.name} to archive:`, error);
// Continue with other files instead of failing entire archive
}
}
// Finalize the archive
await archive.finalize();
this.logger.log(`ZIP stream created with ${files.length} files`);
return outputStream;
} catch (error) {
this.logger.error('Failed to create ZIP stream:', error);
throw error;
}
}
/**
* Add file to archive with EXIF preservation
*/
private async addFileToArchive(
archive: archiver.Archiver,
file: ZipFile,
options: ZipOptions,
): Promise<void> {
try {
// Get file stream from storage
const fileStream = await this.storageService.getFileStream(file.path);
if (options.preserveExif && file.originalPath && this.isImageFile(file.name)) {
// Preserve EXIF data from original image
const processedStream = await this.exifService.preserveExifData(
fileStream as any,
file.originalPath,
);
archive.append(processedStream as any, {
name: this.sanitizeFilename(file.name),
});
} else {
// Add file as-is
archive.append(fileStream as any, {
name: this.sanitizeFilename(file.name),
});
}
this.logger.debug(`Added file to archive: ${file.name}`);
} catch (error) {
this.logger.error(`Failed to add file ${file.name} to archive:`, error);
throw error;
}
}
/**
* Create ZIP buffer from files (for smaller archives)
*/
async createZipBuffer(files: ZipFile[], options: ZipOptions = {}): Promise<Buffer> {
try {
const archive = archiver('zip', {
zlib: {
level: options.compressionLevel || 6,
},
});
const buffers: Buffer[] = [];
return new Promise((resolve, reject) => {
archive.on('data', (chunk) => {
buffers.push(chunk);
});
archive.on('end', () => {
const result = Buffer.concat(buffers);
this.logger.log(`ZIP buffer created: ${result.length} bytes`);
resolve(result);
});
archive.on('error', (err) => {
this.logger.error('Archive error:', err);
reject(err);
});
// Add files to archive
Promise.all(
files.map(file => this.addFileToArchive(archive, file, options))
).then(() => {
archive.finalize();
}).catch(reject);
});
} catch (error) {
this.logger.error('Failed to create ZIP buffer:', error);
throw error;
}
}
/**
* Estimate ZIP size
*/
async estimateZipSize(files: ZipFile[], compressionLevel: number = 0): Promise<number> {
try {
let totalSize = 0;
for (const file of files) {
try {
const fileSize = await this.storageService.getFileSize(file.path);
// For compression level 0 (store only), size is roughly the same
// For higher compression levels, estimate 70-90% of original size for images
const compressionRatio = compressionLevel === 0 ? 1.0 : 0.8;
totalSize += Math.floor(fileSize * compressionRatio);
} catch (error) {
this.logger.warn(`Failed to get size for ${file.path}:`, error);
// Skip this file in size calculation
}
}
// Add ZIP overhead (roughly 30 bytes per file + central directory)
const zipOverhead = files.length * 50;
return totalSize + zipOverhead;
} catch (error) {
this.logger.error('Failed to estimate ZIP size:', error);
throw error;
}
}
/**
* Validate ZIP contents
*/
async validateZipContents(files: ZipFile[]): Promise<{
valid: boolean;
errors: string[];
warnings: string[];
}> {
const errors: string[] = [];
const warnings: string[] = [];
try {
// Check for empty file list
if (files.length === 0) {
errors.push('No files to add to ZIP');
}
// Check for duplicate filenames
const filenames = new Set<string>();
const duplicates = new Set<string>();
for (const file of files) {
const sanitizedName = this.sanitizeFilename(file.name);
if (filenames.has(sanitizedName)) {
duplicates.add(sanitizedName);
}
filenames.add(sanitizedName);
// Check if file exists in storage
try {
await this.storageService.fileExists(file.path);
} catch (error) {
errors.push(`File not found: ${file.name}`);
}
// Validate filename
if (!this.isValidFilename(file.name)) {
warnings.push(`Invalid filename: ${file.name}`);
}
}
if (duplicates.size > 0) {
warnings.push(`Duplicate filenames: ${Array.from(duplicates).join(', ')}`);
}
return {
valid: errors.length === 0,
errors,
warnings,
};
} catch (error) {
this.logger.error('Failed to validate ZIP contents:', error);
return {
valid: false,
errors: ['Failed to validate ZIP contents'],
warnings: [],
};
}
}
/**
* Check if file is an image
*/
private isImageFile(filename: string): boolean {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'];
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return imageExtensions.includes(ext);
}
/**
* Sanitize filename for ZIP archive
*/
private sanitizeFilename(filename: string): string {
// Remove or replace invalid characters
let sanitized = filename
.replace(/[<>:"/\\|?*]/g, '_') // Replace invalid chars with underscore
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
// Ensure filename is not empty
if (!sanitized) {
sanitized = 'unnamed_file';
}
// Ensure filename is not too long (255 char limit for most filesystems)
if (sanitized.length > 255) {
const ext = sanitized.substring(sanitized.lastIndexOf('.'));
const name = sanitized.substring(0, sanitized.lastIndexOf('.'));
sanitized = name.substring(0, 255 - ext.length) + ext;
}
return sanitized;
}
/**
* Validate filename
*/
private isValidFilename(filename: string): boolean {
// Check for empty filename
if (!filename || filename.trim().length === 0) {
return false;
}
// Check for reserved names (Windows)
const reservedNames = [
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
];
const nameWithoutExt = filename.substring(0, filename.lastIndexOf('.') || filename.length);
if (reservedNames.includes(nameWithoutExt.toUpperCase())) {
return false;
}
// Check for invalid characters
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
if (invalidChars.test(filename)) {
return false;
}
return true;
}
/**
* Get optimal compression level for file type
*/
getOptimalCompressionLevel(filename: string): number {
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
// Images are already compressed, so use store only (0) or light compression (1)
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (imageExtensions.includes(ext)) {
return 0; // Store only for faster processing
}
// For other files, use moderate compression
return 6;
}
}

View file

@ -1,166 +0,0 @@
import { IsString, IsEnum, IsOptional, IsObject, IsInt, IsDate } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ImageStatus } from '@prisma/client';
export class ImageResponseDto {
@ApiProperty({
description: 'Image identifier',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@IsString()
id: string;
@ApiProperty({
description: 'Batch identifier this image belongs to',
example: '660f9511-f39c-52e5-b827-557766551111',
})
@IsString()
batch_id: string;
@ApiProperty({
description: 'Original filename',
example: 'IMG_20240101_123456.jpg',
})
@IsString()
original_name: string;
@ApiPropertyOptional({
description: 'AI-generated proposed filename',
example: 'modern-kitchen-with-stainless-steel-appliances.jpg',
})
@IsOptional()
@IsString()
proposed_name?: string;
@ApiPropertyOptional({
description: 'User-approved final filename',
example: 'kitchen-renovation-final.jpg',
})
@IsOptional()
@IsString()
final_name?: string;
@ApiProperty({
description: 'Current processing status',
enum: ImageStatus,
example: ImageStatus.COMPLETED,
})
@IsEnum(ImageStatus)
status: ImageStatus;
@ApiPropertyOptional({
description: 'AI vision analysis results',
example: {
objects: ['kitchen', 'refrigerator', 'countertop'],
colors: ['white', 'stainless steel', 'black'],
scene: 'modern kitchen interior',
description: 'A modern kitchen with stainless steel appliances',
confidence: 0.95,
},
})
@IsOptional()
@IsObject()
vision_tags?: {
objects?: string[];
colors?: string[];
scene?: string;
description?: string;
confidence?: number;
aiModel?: string;
processingTime?: number;
};
@ApiPropertyOptional({
description: 'File size in bytes',
example: 2048576,
})
@IsOptional()
@IsInt()
file_size?: number;
@ApiPropertyOptional({
description: 'Image dimensions',
example: { width: 1920, height: 1080, aspectRatio: '16:9' },
})
@IsOptional()
@IsObject()
dimensions?: {
width: number;
height: number;
format?: string;
};
@ApiPropertyOptional({
description: 'MIME type',
example: 'image/jpeg',
})
@IsOptional()
@IsString()
mime_type?: string;
@ApiPropertyOptional({
description: 'Error message if processing failed',
example: 'AI analysis timeout',
})
@IsOptional()
@IsString()
processing_error?: string;
@ApiProperty({
description: 'Image creation timestamp',
example: '2024-01-01T12:00:00.000Z',
})
@IsDate()
created_at: string;
@ApiProperty({
description: 'Last update timestamp',
example: '2024-01-01T12:05:30.000Z',
})
@IsDate()
updated_at: string;
@ApiPropertyOptional({
description: 'Processing completion timestamp',
example: '2024-01-01T12:05:25.000Z',
})
@IsOptional()
@IsDate()
processed_at?: string;
}
export class BatchImagesResponseDto {
@ApiProperty({
description: 'Batch identifier',
example: '550e8400-e29b-41d4-a716-446655440000',
})
batch_id: string;
@ApiProperty({
description: 'Total number of images in batch',
example: 10,
})
total_images: number;
@ApiProperty({
description: 'Array of images in the batch',
type: [ImageResponseDto],
})
images: ImageResponseDto[];
@ApiProperty({
description: 'Batch status summary',
example: {
pending: 2,
processing: 1,
completed: 6,
failed: 1,
},
})
status_summary: {
pending: number;
processing: number;
completed: number;
failed: number;
};
}

View file

@ -1,43 +0,0 @@
import { IsString, IsNotEmpty, MaxLength, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateFilenameDto {
@ApiProperty({
description: 'New filename for the image (without path, but with extension)',
example: 'modern-kitchen-renovation-2024.jpg',
maxLength: 255,
})
@IsString()
@IsNotEmpty()
@MaxLength(255)
@Matches(/^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,4}$/, {
message: 'Filename must be valid with proper extension',
})
new_name: string;
}
export class UpdateFilenameResponseDto {
@ApiProperty({
description: 'Image identifier',
example: '550e8400-e29b-41d4-a716-446655440000',
})
id: string;
@ApiProperty({
description: 'Updated proposed filename',
example: 'modern-kitchen-renovation-2024.jpg',
})
proposed_name: string;
@ApiProperty({
description: 'Original filename',
example: 'IMG_20240101_123456.jpg',
})
original_name: string;
@ApiProperty({
description: 'Update timestamp',
example: '2024-01-01T12:05:30.000Z',
})
updated_at: string;
}

View file

@ -1,349 +0,0 @@
import {
IsString,
IsEnum,
IsInt,
IsOptional,
IsUUID,
IsObject,
MinLength,
MaxLength,
Min,
IsDate
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ImageStatus } from '@prisma/client';
import { Type } from 'class-transformer';
export interface VisionTagsInterface {
objects?: string[];
colors?: string[];
scene?: string;
description?: string;
confidence?: number;
aiModel?: string;
processingTime?: number;
}
export interface ImageDimensionsInterface {
width: number;
height: number;
aspectRatio?: string;
}
export class CreateImageDto {
@ApiProperty({
description: 'ID of the batch this image belongs to',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
batchId: string;
@ApiProperty({
description: 'Original filename of the image',
example: 'IMG_20240101_123456.jpg'
})
@IsString()
@MinLength(1)
@MaxLength(255)
originalName: string;
@ApiPropertyOptional({
description: 'File size in bytes',
example: 2048576
})
@IsOptional()
@IsInt()
@Min(0)
fileSize?: number;
@ApiPropertyOptional({
description: 'MIME type of the image',
example: 'image/jpeg'
})
@IsOptional()
@IsString()
mimeType?: string;
@ApiPropertyOptional({
description: 'Image dimensions',
example: { width: 1920, height: 1080, aspectRatio: '16:9' }
})
@IsOptional()
@IsObject()
dimensions?: ImageDimensionsInterface;
@ApiPropertyOptional({
description: 'S3 object key for storage',
example: 'uploads/user123/batch456/original/image.jpg'
})
@IsOptional()
@IsString()
s3Key?: string;
}
export class UpdateImageDto {
@ApiPropertyOptional({
description: 'AI-generated proposed filename',
example: 'modern-kitchen-with-stainless-steel-appliances.jpg'
})
@IsOptional()
@IsString()
@MaxLength(255)
proposedName?: string;
@ApiPropertyOptional({
description: 'User-approved final filename',
example: 'kitchen-renovation-final.jpg'
})
@IsOptional()
@IsString()
@MaxLength(255)
finalName?: string;
@ApiPropertyOptional({
description: 'AI vision analysis results',
example: {
objects: ['kitchen', 'refrigerator', 'countertop'],
colors: ['white', 'stainless steel', 'black'],
scene: 'modern kitchen interior',
description: 'A modern kitchen with stainless steel appliances',
confidence: 0.95,
aiModel: 'gpt-4-vision',
processingTime: 2.5
}
})
@IsOptional()
@IsObject()
visionTags?: VisionTagsInterface;
@ApiPropertyOptional({
description: 'Image processing status',
enum: ImageStatus
})
@IsOptional()
@IsEnum(ImageStatus)
status?: ImageStatus;
@ApiPropertyOptional({
description: 'Error message if processing failed',
example: 'Image format not supported'
})
@IsOptional()
@IsString()
@MaxLength(500)
processingError?: string;
}
export class ImageResponseDto {
@ApiProperty({
description: 'Unique image identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
id: string;
@ApiProperty({
description: 'ID of the batch this image belongs to',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
batchId: string;
@ApiProperty({
description: 'Original filename of the image',
example: 'IMG_20240101_123456.jpg'
})
@IsString()
originalName: string;
@ApiPropertyOptional({
description: 'AI-generated proposed filename',
example: 'modern-kitchen-with-stainless-steel-appliances.jpg'
})
@IsOptional()
@IsString()
proposedName?: string;
@ApiPropertyOptional({
description: 'User-approved final filename',
example: 'kitchen-renovation-final.jpg'
})
@IsOptional()
@IsString()
finalName?: string;
@ApiPropertyOptional({
description: 'AI vision analysis results'
})
@IsOptional()
@IsObject()
visionTags?: VisionTagsInterface;
@ApiProperty({
description: 'Current image processing status',
enum: ImageStatus
})
@IsEnum(ImageStatus)
status: ImageStatus;
@ApiPropertyOptional({
description: 'File size in bytes',
example: 2048576
})
@IsOptional()
@IsInt()
fileSize?: number;
@ApiPropertyOptional({
description: 'Image dimensions'
})
@IsOptional()
@IsObject()
dimensions?: ImageDimensionsInterface;
@ApiPropertyOptional({
description: 'MIME type of the image',
example: 'image/jpeg'
})
@IsOptional()
@IsString()
mimeType?: string;
@ApiPropertyOptional({
description: 'S3 object key for storage',
example: 'uploads/user123/batch456/original/image.jpg'
})
@IsOptional()
@IsString()
s3Key?: string;
@ApiPropertyOptional({
description: 'Error message if processing failed'
})
@IsOptional()
@IsString()
processingError?: string;
@ApiProperty({
description: 'Image creation timestamp'
})
@IsDate()
createdAt: Date;
@ApiProperty({
description: 'Image last update timestamp'
})
@IsDate()
updatedAt: Date;
@ApiPropertyOptional({
description: 'Image processing completion timestamp'
})
@IsOptional()
@IsDate()
processedAt?: Date;
}
export class ImageProcessingResultDto {
@ApiProperty({
description: 'Image details'
})
image: ImageResponseDto;
@ApiProperty({
description: 'Processing success status'
})
success: boolean;
@ApiPropertyOptional({
description: 'Processing time in seconds'
})
@IsOptional()
@Type(() => Number)
processingTime?: number;
@ApiPropertyOptional({
description: 'Error details if processing failed'
})
@IsOptional()
@IsString()
error?: string;
}
export class BulkImageUpdateDto {
@ApiProperty({
description: 'Array of image IDs to update',
example: ['550e8400-e29b-41d4-a716-446655440000', '660f9511-f39c-52e5-b827-557766551111']
})
@IsUUID(undefined, { each: true })
imageIds: string[];
@ApiPropertyOptional({
description: 'Status to set for all images',
enum: ImageStatus
})
@IsOptional()
@IsEnum(ImageStatus)
status?: ImageStatus;
@ApiPropertyOptional({
description: 'Apply proposed names as final names for all images'
})
@IsOptional()
applyProposedNames?: boolean;
}
// Helper function to generate SEO-friendly filename
export function generateSeoFriendlyFilename(
visionTags: VisionTagsInterface,
originalName: string
): string {
if (!visionTags.objects && !visionTags.description) {
return originalName;
}
let filename = '';
// Use description if available, otherwise use objects
if (visionTags.description) {
filename = visionTags.description
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.substring(0, 100); // Limit length
} else if (visionTags.objects && visionTags.objects.length > 0) {
filename = visionTags.objects
.slice(0, 3) // Take first 3 objects
.join('-')
.toLowerCase()
.replace(/[^a-z0-9-]/g, '')
.substring(0, 100);
}
// Get file extension from original name
const extension = originalName.split('.').pop()?.toLowerCase() || 'jpg';
return filename ? `${filename}.${extension}` : originalName;
}
// Helper function to validate image file type
export function isValidImageType(mimeType: string): boolean {
const validTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
'image/gif',
'image/bmp',
'image/tiff'
];
return validTypes.includes(mimeType.toLowerCase());
}
// Helper function to calculate aspect ratio
export function calculateAspectRatio(width: number, height: number): string {
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
const divisor = gcd(width, height);
return `${width / divisor}:${height / divisor}`;
}

View file

@ -1,304 +0,0 @@
import {
Controller,
Get,
Put,
Param,
Body,
UseGuards,
Request,
HttpStatus,
BadRequestException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/auth.guard';
import { ImagesService } from './images.service';
import { UpdateFilenameDto, UpdateFilenameResponseDto } from './dto/update-filename.dto';
import { ImageResponseDto, BatchImagesResponseDto } from './dto/image-response.dto';
@ApiTags('images')
@Controller('api/image')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class ImagesController {
constructor(private readonly imagesService: ImagesService) {}
@Put(':imageId/filename')
@ApiOperation({
summary: 'Update image filename',
description: 'Updates the proposed filename for a specific image',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Filename updated successfully',
type: UpdateFilenameResponseDto,
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Invalid filename or request data',
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Image not found',
})
@ApiResponse({
status: HttpStatus.FORBIDDEN,
description: 'Not authorized to update this image',
})
async updateImageFilename(
@Param('imageId') imageId: string,
@Body() updateFilenameDto: UpdateFilenameDto,
@Request() req: any,
): Promise<UpdateFilenameResponseDto> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
const result = await this.imagesService.updateFilename(
imageId,
userId,
updateFilenameDto.new_name
);
return result;
} catch (error) {
if (
error instanceof BadRequestException ||
error instanceof ForbiddenException ||
error instanceof NotFoundException
) {
throw error;
}
throw new BadRequestException('Failed to update image filename');
}
}
@Get(':imageId')
@ApiOperation({
summary: 'Get image details',
description: 'Returns detailed information about a specific image',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Image details retrieved successfully',
type: ImageResponseDto,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Image not found',
})
@ApiResponse({
status: HttpStatus.FORBIDDEN,
description: 'Not authorized to access this image',
})
async getImage(
@Param('imageId') imageId: string,
@Request() req: any,
): Promise<ImageResponseDto> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
const image = await this.imagesService.getImage(imageId, userId);
return image;
} catch (error) {
if (
error instanceof BadRequestException ||
error instanceof ForbiddenException ||
error instanceof NotFoundException
) {
throw error;
}
throw new BadRequestException('Failed to get image details');
}
}
@Get('batch/:batchId')
@ApiOperation({
summary: 'Get all images in a batch',
description: 'Returns all images belonging to a specific batch',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Batch images retrieved successfully',
type: BatchImagesResponseDto,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Batch not found',
})
@ApiResponse({
status: HttpStatus.FORBIDDEN,
description: 'Not authorized to access this batch',
})
async getBatchImages(
@Param('batchId') batchId: string,
@Request() req: any,
): Promise<BatchImagesResponseDto> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
const batchImages = await this.imagesService.getBatchImages(batchId, userId);
return batchImages;
} catch (error) {
if (
error instanceof BadRequestException ||
error instanceof ForbiddenException ||
error instanceof NotFoundException
) {
throw error;
}
throw new BadRequestException('Failed to get batch images');
}
}
@Get(':imageId/download')
@ApiOperation({
summary: 'Get image download URL',
description: 'Returns a presigned URL for downloading the original or processed image',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Download URL generated successfully',
schema: {
type: 'object',
properties: {
download_url: {
type: 'string',
example: 'https://storage.example.com/images/processed/image.jpg?expires=...',
},
expires_at: {
type: 'string',
example: '2024-01-01T13:00:00.000Z',
},
filename: {
type: 'string',
example: 'modern-kitchen-renovation.jpg',
},
},
},
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Image not found',
})
@ApiResponse({
status: HttpStatus.FORBIDDEN,
description: 'Not authorized to download this image',
})
async getImageDownloadUrl(
@Param('imageId') imageId: string,
@Request() req: any,
): Promise<{
download_url: string;
expires_at: string;
filename: string;
}> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
const downloadInfo = await this.imagesService.getImageDownloadUrl(imageId, userId);
return downloadInfo;
} catch (error) {
if (
error instanceof BadRequestException ||
error instanceof ForbiddenException ||
error instanceof NotFoundException
) {
throw error;
}
throw new BadRequestException('Failed to generate download URL');
}
}
@Put(':imageId/approve')
@ApiOperation({
summary: 'Approve proposed filename',
description: 'Approves the AI-generated proposed filename as the final filename',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Filename approved successfully',
type: UpdateFilenameResponseDto,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Image not found',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'No proposed filename to approve',
})
async approveFilename(
@Param('imageId') imageId: string,
@Request() req: any,
): Promise<UpdateFilenameResponseDto> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
const result = await this.imagesService.approveProposedFilename(imageId, userId);
return result;
} catch (error) {
if (
error instanceof BadRequestException ||
error instanceof ForbiddenException ||
error instanceof NotFoundException
) {
throw error;
}
throw new BadRequestException('Failed to approve filename');
}
}
@Put(':imageId/revert')
@ApiOperation({
summary: 'Revert to original filename',
description: 'Reverts the image filename back to the original uploaded filename',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Filename reverted successfully',
type: UpdateFilenameResponseDto,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Image not found',
})
async revertFilename(
@Param('imageId') imageId: string,
@Request() req: any,
): Promise<UpdateFilenameResponseDto> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
const result = await this.imagesService.revertToOriginalFilename(imageId, userId);
return result;
} catch (error) {
if (
error instanceof BadRequestException ||
error instanceof ForbiddenException ||
error instanceof NotFoundException
) {
throw error;
}
throw new BadRequestException('Failed to revert filename');
}
}
}

View file

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { StorageModule } from '../storage/storage.module';
import { ImagesController } from './images.controller';
import { ImagesService } from './images.service';
@Module({
imports: [DatabaseModule, StorageModule],
controllers: [ImagesController],
providers: [ImagesService],
exports: [ImagesService],
})
export class ImagesModule {}

View file

@ -1,442 +0,0 @@
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { ImageStatus } from '@prisma/client';
import { PrismaService } from '../database/prisma.service';
import { StorageService } from '../storage/storage.service';
import { UpdateFilenameResponseDto } from './dto/update-filename.dto';
import { ImageResponseDto, BatchImagesResponseDto } from './dto/image-response.dto';
@Injectable()
export class ImagesService {
private readonly logger = new Logger(ImagesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly storageService: StorageService,
) {}
/**
* Update image filename
*/
async updateFilename(
imageId: string,
userId: string,
newName: string,
): Promise<UpdateFilenameResponseDto> {
try {
// Find image and verify ownership
const image = await this.prisma.image.findFirst({
where: {
id: imageId,
batch: { userId },
},
include: {
batch: { select: { userId: true } },
},
});
if (!image) {
throw new NotFoundException('Image not found');
}
// Validate filename
if (!this.isValidFilename(newName)) {
throw new BadRequestException('Invalid filename format');
}
// Ensure filename has proper extension
if (!this.hasValidExtension(newName)) {
throw new BadRequestException('Filename must have a valid image extension');
}
// Update the proposed name
const updatedImage = await this.prisma.image.update({
where: { id: imageId },
data: {
proposedName: newName,
updatedAt: new Date(),
},
});
this.logger.log(`Updated filename for image: ${imageId} to: ${newName}`);
return {
id: updatedImage.id,
proposed_name: updatedImage.proposedName!,
original_name: updatedImage.originalName,
updated_at: updatedImage.updatedAt.toISOString(),
};
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof BadRequestException
) {
throw error;
}
this.logger.error(`Failed to update filename for image: ${imageId}`, error.stack);
throw new BadRequestException('Failed to update image filename');
}
}
/**
* Get image details
*/
async getImage(imageId: string, userId: string): Promise<ImageResponseDto> {
try {
const image = await this.prisma.image.findFirst({
where: {
id: imageId,
batch: { userId },
},
});
if (!image) {
throw new NotFoundException('Image not found');
}
return this.mapImageToResponse(image);
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Failed to get image: ${imageId}`, error.stack);
throw new BadRequestException('Failed to get image details');
}
}
/**
* Get all images in a batch
*/
async getBatchImages(batchId: string, userId: string): Promise<BatchImagesResponseDto> {
try {
// Verify batch ownership
const batch = await this.prisma.batch.findFirst({
where: {
id: batchId,
userId,
},
include: {
images: {
orderBy: { createdAt: 'asc' },
},
},
});
if (!batch) {
throw new NotFoundException('Batch not found');
}
// Calculate status summary
const statusSummary = {
pending: 0,
processing: 0,
completed: 0,
failed: 0,
};
batch.images.forEach((image) => {
switch (image.status) {
case ImageStatus.PENDING:
statusSummary.pending++;
break;
case ImageStatus.PROCESSING:
statusSummary.processing++;
break;
case ImageStatus.COMPLETED:
statusSummary.completed++;
break;
case ImageStatus.FAILED:
statusSummary.failed++;
break;
}
});
return {
batch_id: batchId,
total_images: batch.images.length,
images: batch.images.map(this.mapImageToResponse),
status_summary: statusSummary,
};
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Failed to get batch images: ${batchId}`, error.stack);
throw new BadRequestException('Failed to get batch images');
}
}
/**
* Get presigned download URL for image
*/
async getImageDownloadUrl(
imageId: string,
userId: string,
): Promise<{
download_url: string;
expires_at: string;
filename: string;
}> {
try {
const image = await this.prisma.image.findFirst({
where: {
id: imageId,
batch: { userId },
},
});
if (!image) {
throw new NotFoundException('Image not found');
}
if (!image.s3Key) {
throw new BadRequestException('Image file not available for download');
}
// Generate presigned URL (expires in 1 hour)
const downloadUrl = await this.storageService.getPresignedUrl(image.s3Key, 3600);
const expiresAt = new Date(Date.now() + 3600 * 1000);
// Use final name if available, otherwise proposed name, otherwise original name
const filename = image.finalName || image.proposedName || image.originalName;
this.logger.log(`Generated download URL for image: ${imageId}`);
return {
download_url: downloadUrl,
expires_at: expiresAt.toISOString(),
filename,
};
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof BadRequestException
) {
throw error;
}
this.logger.error(`Failed to generate download URL for image: ${imageId}`, error.stack);
throw new BadRequestException('Failed to generate download URL');
}
}
/**
* Approve the proposed filename as final
*/
async approveProposedFilename(
imageId: string,
userId: string,
): Promise<UpdateFilenameResponseDto> {
try {
const image = await this.prisma.image.findFirst({
where: {
id: imageId,
batch: { userId },
},
});
if (!image) {
throw new NotFoundException('Image not found');
}
if (!image.proposedName) {
throw new BadRequestException('No proposed filename to approve');
}
const updatedImage = await this.prisma.image.update({
where: { id: imageId },
data: {
finalName: image.proposedName,
updatedAt: new Date(),
},
});
this.logger.log(`Approved filename for image: ${imageId}`);
return {
id: updatedImage.id,
proposed_name: updatedImage.proposedName!,
original_name: updatedImage.originalName,
updated_at: updatedImage.updatedAt.toISOString(),
};
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof BadRequestException
) {
throw error;
}
this.logger.error(`Failed to approve filename for image: ${imageId}`, error.stack);
throw new BadRequestException('Failed to approve filename');
}
}
/**
* Revert to original filename
*/
async revertToOriginalFilename(
imageId: string,
userId: string,
): Promise<UpdateFilenameResponseDto> {
try {
const image = await this.prisma.image.findFirst({
where: {
id: imageId,
batch: { userId },
},
});
if (!image) {
throw new NotFoundException('Image not found');
}
const updatedImage = await this.prisma.image.update({
where: { id: imageId },
data: {
proposedName: image.originalName,
finalName: null,
updatedAt: new Date(),
},
});
this.logger.log(`Reverted filename for image: ${imageId} to original`);
return {
id: updatedImage.id,
proposed_name: updatedImage.proposedName!,
original_name: updatedImage.originalName,
updated_at: updatedImage.updatedAt.toISOString(),
};
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Failed to revert filename for image: ${imageId}`, error.stack);
throw new BadRequestException('Failed to revert filename');
}
}
/**
* Update image processing status (called by queue processors)
*/
async updateImageStatus(
imageId: string,
status: ImageStatus,
visionTags?: any,
proposedName?: string,
error?: string,
): Promise<void> {
try {
const updateData: any = {
status,
updatedAt: new Date(),
};
if (visionTags) {
updateData.visionTags = visionTags;
}
if (proposedName) {
updateData.proposedName = proposedName;
}
if (error) {
updateData.processingError = error;
}
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
updateData.processedAt = new Date();
}
await this.prisma.image.update({
where: { id: imageId },
data: updateData,
});
this.logger.debug(`Updated image status: ${imageId} to ${status}`);
} catch (error) {
this.logger.error(`Failed to update image status: ${imageId}`, error.stack);
}
}
/**
* Get images by status (for queue processing)
*/
async getImagesByStatus(batchId: string, status: ImageStatus) {
try {
return await this.prisma.image.findMany({
where: {
batchId,
status,
},
select: {
id: true,
originalName: true,
s3Key: true,
},
});
} catch (error) {
this.logger.error(`Failed to get images by status: ${batchId}`, error.stack);
return [];
}
}
/**
* Map database image to response DTO
*/
private mapImageToResponse(image: any): ImageResponseDto {
return {
id: image.id,
batch_id: image.batchId,
original_name: image.originalName,
proposed_name: image.proposedName,
final_name: image.finalName,
status: image.status,
vision_tags: image.visionTags,
file_size: image.fileSize,
dimensions: image.dimensions,
mime_type: image.mimeType,
processing_error: image.processingError,
created_at: image.createdAt.toISOString(),
updated_at: image.updatedAt.toISOString(),
processed_at: image.processedAt?.toISOString(),
};
}
/**
* Validate filename format
*/
private isValidFilename(filename: string): boolean {
// Check for invalid characters
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
if (invalidChars.test(filename)) {
return false;
}
// Check length
if (filename.length === 0 || filename.length > 255) {
return false;
}
// Check for reserved names
const reservedNames = [
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
];
const nameWithoutExt = filename.split('.')[0].toUpperCase();
if (reservedNames.includes(nameWithoutExt)) {
return false;
}
return true;
}
/**
* Check if filename has valid image extension
*/
private hasValidExtension(filename: string): boolean {
const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'];
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return validExtensions.includes(extension);
}
}

View file

@ -1,79 +0,0 @@
import { IsArray, IsString, ArrayMaxSize, ArrayMinSize, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class EnhanceKeywordsDto {
@ApiProperty({
description: 'Array of keywords to enhance with AI suggestions',
example: ['kitchen', 'modern', 'renovation'],
minItems: 1,
maxItems: 20,
})
@IsArray()
@IsString({ each: true })
@ArrayMinSize(1)
@ArrayMaxSize(20)
@MaxLength(50, { each: true })
keywords: string[];
}
export class EnhanceKeywordsResponseDto {
@ApiProperty({
description: 'Original keywords provided',
example: ['kitchen', 'modern', 'renovation'],
})
original_keywords: string[];
@ApiProperty({
description: 'AI-enhanced keywords with SEO improvements',
example: [
'modern-kitchen-design',
'contemporary-kitchen-renovation',
'sleek-kitchen-remodel',
'updated-kitchen-interior',
'kitchen-makeover-ideas',
'stylish-kitchen-upgrade',
'fresh-kitchen-design',
'kitchen-transformation'
],
})
enhanced_keywords: string[];
@ApiProperty({
description: 'Related keywords and synonyms',
example: [
'culinary-space',
'cooking-area',
'kitchen-cabinets',
'kitchen-appliances',
'kitchen-island',
'backsplash-design'
],
})
related_keywords: string[];
@ApiProperty({
description: 'SEO-optimized long-tail keywords',
example: [
'modern-kitchen-renovation-ideas-2024',
'contemporary-kitchen-design-trends',
'sleek-kitchen-remodel-inspiration'
],
})
long_tail_keywords: string[];
@ApiProperty({
description: 'Processing metadata',
example: {
processing_time: 1.2,
ai_model: 'gpt-4',
confidence_score: 0.92,
keywords_generated: 15,
},
})
metadata: {
processing_time: number;
ai_model: string;
confidence_score: number;
keywords_generated: number;
};
}

View file

@ -1,192 +0,0 @@
import {
Controller,
Post,
Body,
UseGuards,
Request,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/auth.guard';
import { KeywordsService } from './keywords.service';
import { EnhanceKeywordsDto, EnhanceKeywordsResponseDto } from './dto/enhance-keywords.dto';
@ApiTags('keywords')
@Controller('api/keywords')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class KeywordsController {
constructor(private readonly keywordsService: KeywordsService) {}
@Post('enhance')
@ApiOperation({
summary: 'Enhance keywords with AI suggestions',
description: 'Takes user-provided keywords and returns AI-enhanced SEO-optimized keywords and suggestions',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Keywords enhanced successfully',
type: EnhanceKeywordsResponseDto,
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Invalid keywords or request data',
})
@ApiResponse({
status: HttpStatus.TOO_MANY_REQUESTS,
description: 'Rate limit exceeded for keyword enhancement',
})
async enhanceKeywords(
@Body() enhanceKeywordsDto: EnhanceKeywordsDto,
@Request() req: any,
): Promise<EnhanceKeywordsResponseDto> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
// Check rate limits
await this.keywordsService.checkRateLimit(userId);
// Enhance keywords with AI
const enhancedResult = await this.keywordsService.enhanceKeywords(
enhanceKeywordsDto.keywords,
userId,
);
return enhancedResult;
} catch (error) {
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException('Failed to enhance keywords');
}
}
@Post('suggest')
@ApiOperation({
summary: 'Get keyword suggestions for image context',
description: 'Provides keyword suggestions based on image analysis context',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Keyword suggestions generated successfully',
schema: {
type: 'object',
properties: {
suggestions: {
type: 'array',
items: { type: 'string' },
example: ['interior-design', 'home-decor', 'modern-style', 'contemporary'],
},
categories: {
type: 'object',
example: {
style: ['modern', 'contemporary', 'minimalist'],
room: ['kitchen', 'living-room', 'bedroom'],
color: ['white', 'black', 'gray'],
material: ['wood', 'metal', 'glass'],
},
},
},
},
})
async getKeywordSuggestions(
@Body() body: { context?: string; category?: string },
@Request() req: any,
): Promise<{
suggestions: string[];
categories: Record<string, string[]>;
}> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
const suggestions = await this.keywordsService.getKeywordSuggestions(
body.context,
body.category,
);
return suggestions;
} catch (error) {
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException('Failed to get keyword suggestions');
}
}
@Post('validate')
@ApiOperation({
summary: 'Validate keywords for SEO optimization',
description: 'Checks keywords for SEO best practices and provides recommendations',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Keywords validated successfully',
schema: {
type: 'object',
properties: {
valid_keywords: {
type: 'array',
items: { type: 'string' },
example: ['modern-kitchen', 'contemporary-design'],
},
invalid_keywords: {
type: 'array',
items: {
type: 'object',
properties: {
keyword: { type: 'string' },
reason: { type: 'string' },
},
},
example: [
{ keyword: 'a', reason: 'Too short for SEO value' },
{ keyword: 'the-best-kitchen-in-the-world-ever', reason: 'Too long for practical use' },
],
},
recommendations: {
type: 'array',
items: { type: 'string' },
example: [
'Use hyphens instead of spaces',
'Keep keywords between 2-4 words',
'Avoid stop words like "the", "and", "or"',
],
},
},
},
})
async validateKeywords(
@Body() body: { keywords: string[] },
@Request() req: any,
): Promise<{
valid_keywords: string[];
invalid_keywords: Array<{ keyword: string; reason: string }>;
recommendations: string[];
}> {
try {
const userId = req.user?.id;
if (!userId) {
throw new BadRequestException('User not authenticated');
}
if (!body.keywords || !Array.isArray(body.keywords)) {
throw new BadRequestException('Keywords array is required');
}
const validation = await this.keywordsService.validateKeywords(body.keywords);
return validation;
} catch (error) {
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException('Failed to validate keywords');
}
}
}

View file

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { KeywordsController } from './keywords.controller';
import { KeywordsService } from './keywords.service';
@Module({
imports: [ConfigModule],
controllers: [KeywordsController],
providers: [KeywordsService],
exports: [KeywordsService],
})
export class KeywordsModule {}

View file

@ -1,345 +0,0 @@
import { Injectable, Logger, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EnhanceKeywordsResponseDto } from './dto/enhance-keywords.dto';
// import OpenAI from 'openai'; // Uncomment when ready to use actual OpenAI integration
@Injectable()
export class KeywordsService {
private readonly logger = new Logger(KeywordsService.name);
// private readonly openai: OpenAI; // Uncomment when ready to use actual OpenAI
private readonly rateLimitMap = new Map<string, { count: number; resetTime: number }>();
private readonly RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
private readonly RATE_LIMIT_MAX_REQUESTS = 10; // 10 requests per minute per user
constructor(private readonly configService: ConfigService) {
// Initialize OpenAI client when ready
// this.openai = new OpenAI({
// apiKey: this.configService.get<string>('OPENAI_API_KEY'),
// });
}
/**
* Enhance keywords with AI suggestions
*/
async enhanceKeywords(
keywords: string[],
userId: string,
): Promise<EnhanceKeywordsResponseDto> {
const startTime = Date.now();
try {
this.logger.log(`Enhancing keywords for user: ${userId}`);
// Clean and normalize input keywords
const cleanKeywords = this.cleanKeywords(keywords);
// Generate enhanced keywords using AI
const enhancedKeywords = await this.generateEnhancedKeywords(cleanKeywords);
const relatedKeywords = await this.generateRelatedKeywords(cleanKeywords);
const longTailKeywords = await this.generateLongTailKeywords(cleanKeywords);
const processingTime = (Date.now() - startTime) / 1000;
const result: EnhanceKeywordsResponseDto = {
original_keywords: cleanKeywords,
enhanced_keywords: enhancedKeywords,
related_keywords: relatedKeywords,
long_tail_keywords: longTailKeywords,
metadata: {
processing_time: processingTime,
ai_model: 'mock-gpt-4', // Replace with actual model when using OpenAI
confidence_score: 0.92,
keywords_generated: enhancedKeywords.length + relatedKeywords.length + longTailKeywords.length,
},
};
this.logger.log(`Enhanced keywords successfully for user: ${userId}`);
return result;
} catch (error) {
this.logger.error(`Failed to enhance keywords for user: ${userId}`, error.stack);
throw new BadRequestException('Failed to enhance keywords');
}
}
/**
* Get keyword suggestions based on context
*/
async getKeywordSuggestions(
context?: string,
category?: string,
): Promise<{
suggestions: string[];
categories: Record<string, string[]>;
}> {
try {
// Mock suggestions - replace with actual AI generation
const baseSuggestions = [
'interior-design',
'home-decor',
'modern-style',
'contemporary',
'minimalist',
'elegant',
'stylish',
'trendy',
];
const categories = {
style: ['modern', 'contemporary', 'minimalist', 'industrial', 'scandinavian', 'rustic'],
room: ['kitchen', 'living-room', 'bedroom', 'bathroom', 'office', 'dining-room'],
color: ['white', 'black', 'gray', 'blue', 'green', 'brown'],
material: ['wood', 'metal', 'glass', 'stone', 'fabric', 'leather'],
feature: ['island', 'cabinet', 'counter', 'lighting', 'flooring', 'window'],
};
// Filter suggestions based on context or category
let suggestions = baseSuggestions;
if (category && categories[category]) {
suggestions = [...baseSuggestions, ...categories[category]];
}
return {
suggestions: suggestions.slice(0, 12), // Limit to 12 suggestions
categories,
};
} catch (error) {
this.logger.error('Failed to get keyword suggestions', error.stack);
throw new BadRequestException('Failed to get keyword suggestions');
}
}
/**
* Validate keywords for SEO optimization
*/
async validateKeywords(keywords: string[]): Promise<{
valid_keywords: string[];
invalid_keywords: Array<{ keyword: string; reason: string }>;
recommendations: string[];
}> {
try {
const validKeywords: string[] = [];
const invalidKeywords: Array<{ keyword: string; reason: string }> = [];
const recommendations: string[] = [];
for (const keyword of keywords) {
const validation = this.validateSingleKeyword(keyword);
if (validation.isValid) {
validKeywords.push(keyword);
} else {
invalidKeywords.push({
keyword,
reason: validation.reason,
});
}
}
// Generate recommendations
if (invalidKeywords.some(item => item.reason.includes('spaces'))) {
recommendations.push('Use hyphens instead of spaces for better SEO');
}
if (invalidKeywords.some(item => item.reason.includes('short'))) {
recommendations.push('Keywords should be at least 2 characters long');
}
if (invalidKeywords.some(item => item.reason.includes('long'))) {
recommendations.push('Keep keywords concise, ideally 2-4 words');
}
if (keywords.some(k => /\b(the|and|or|but|in|on|at|to|for|of|with|by)\b/i.test(k))) {
recommendations.push('Avoid stop words like "the", "and", "or" for better SEO');
}
return {
valid_keywords: validKeywords,
invalid_keywords: invalidKeywords,
recommendations,
};
} catch (error) {
this.logger.error('Failed to validate keywords', error.stack);
throw new BadRequestException('Failed to validate keywords');
}
}
/**
* Check rate limit for user
*/
async checkRateLimit(userId: string): Promise<void> {
const now = Date.now();
const userLimit = this.rateLimitMap.get(userId);
if (!userLimit || now > userLimit.resetTime) {
// Reset or create new limit window
this.rateLimitMap.set(userId, {
count: 1,
resetTime: now + this.RATE_LIMIT_WINDOW,
});
return;
}
if (userLimit.count >= this.RATE_LIMIT_MAX_REQUESTS) {
throw new HttpException(
'Rate limit exceeded. Try again later.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
userLimit.count++;
}
/**
* Clean and normalize keywords
*/
private cleanKeywords(keywords: string[]): string[] {
return keywords
.map(keyword => keyword.trim().toLowerCase())
.filter(keyword => keyword.length > 0)
.filter((keyword, index, arr) => arr.indexOf(keyword) === index); // Remove duplicates
}
/**
* Generate enhanced keywords using AI (mock implementation)
*/
private async generateEnhancedKeywords(keywords: string[]): Promise<string[]> {
// Simulate AI processing time
await new Promise(resolve => setTimeout(resolve, 500));
// Mock enhanced keywords - replace with actual AI generation
const enhancementPrefixes = ['modern', 'contemporary', 'sleek', 'stylish', 'elegant', 'trendy'];
const enhancementSuffixes = ['design', 'style', 'decor', 'interior', 'renovation', 'makeover'];
const enhanced: string[] = [];
for (const keyword of keywords) {
// Create variations with prefixes and suffixes
enhancementPrefixes.forEach(prefix => {
if (!keyword.startsWith(prefix)) {
enhanced.push(`${prefix}-${keyword}`);
}
});
enhancementSuffixes.forEach(suffix => {
if (!keyword.endsWith(suffix)) {
enhanced.push(`${keyword}-${suffix}`);
}
});
// Create compound keywords
if (keywords.length > 1) {
keywords.forEach(otherKeyword => {
if (keyword !== otherKeyword) {
enhanced.push(`${keyword}-${otherKeyword}`);
}
});
}
}
// Remove duplicates and limit results
return [...new Set(enhanced)].slice(0, 8);
}
/**
* Generate related keywords (mock implementation)
*/
private async generateRelatedKeywords(keywords: string[]): Promise<string[]> {
// Simulate AI processing time
await new Promise(resolve => setTimeout(resolve, 300));
// Mock related keywords - replace with actual AI generation
const relatedMap: Record<string, string[]> = {
kitchen: ['culinary-space', 'cooking-area', 'kitchen-cabinets', 'kitchen-appliances', 'kitchen-island'],
modern: ['contemporary', 'minimalist', 'sleek', 'current', 'updated'],
renovation: ['remodel', 'makeover', 'upgrade', 'transformation', 'improvement'],
design: ['decor', 'style', 'interior', 'aesthetic', 'layout'],
};
const related: string[] = [];
keywords.forEach(keyword => {
if (relatedMap[keyword]) {
related.push(...relatedMap[keyword]);
}
});
// Add generic related terms
const genericRelated = [
'home-improvement',
'interior-design',
'space-optimization',
'aesthetic-enhancement',
];
return [...new Set([...related, ...genericRelated])].slice(0, 6);
}
/**
* Generate long-tail keywords (mock implementation)
*/
private async generateLongTailKeywords(keywords: string[]): Promise<string[]> {
// Simulate AI processing time
await new Promise(resolve => setTimeout(resolve, 400));
const currentYear = new Date().getFullYear();
const longTailTemplates = [
`{keyword}-ideas-${currentYear}`,
`{keyword}-trends-${currentYear}`,
`{keyword}-inspiration-gallery`,
`best-{keyword}-designs`,
`{keyword}-before-and-after`,
`affordable-{keyword}-solutions`,
];
const longTail: string[] = [];
keywords.forEach(keyword => {
longTailTemplates.forEach(template => {
longTail.push(template.replace('{keyword}', keyword));
});
});
// Create compound long-tail keywords
if (keywords.length >= 2) {
const compound = keywords.slice(0, 2).join('-');
longTail.push(`${compound}-design-ideas-${currentYear}`);
longTail.push(`${compound}-renovation-guide`);
longTail.push(`${compound}-style-trends`);
}
return [...new Set(longTail)].slice(0, 4);
}
/**
* Validate a single keyword
*/
private validateSingleKeyword(keyword: string): { isValid: boolean; reason: string } {
// Check length
if (keyword.length < 2) {
return { isValid: false, reason: 'Too short for SEO value' };
}
if (keyword.length > 60) {
return { isValid: false, reason: 'Too long for practical use' };
}
// Check for spaces (should use hyphens)
if (keyword.includes(' ')) {
return { isValid: false, reason: 'Use hyphens instead of spaces' };
}
// Check for invalid characters
if (!/^[a-zA-Z0-9-_]+$/.test(keyword)) {
return { isValid: false, reason: 'Contains invalid characters' };
}
// Check for double hyphens or underscores
if (keyword.includes('--') || keyword.includes('__')) {
return { isValid: false, reason: 'Avoid double hyphens or underscores' };
}
// Check if starts or ends with hyphen/underscore
if (keyword.startsWith('-') || keyword.endsWith('-') ||
keyword.startsWith('_') || keyword.endsWith('_')) {
return { isValid: false, reason: 'Should not start or end with hyphen or underscore' };
}
return { isValid: true, reason: '' };
}
}

View file

@ -1,105 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import helmet from 'helmet';
import * as compression from 'compression';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Global prefix for API routes
app.setGlobalPrefix('api');
// Enable CORS
app.enableCors({
origin: configService.get<string>('CORS_ORIGIN', 'http://localhost:3000'),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'X-Requested-With',
'X-CSRF-Token',
'Accept',
],
});
// Security middleware
app.use(helmet({
contentSecurityPolicy: false, // We handle CSP in our custom middleware
crossOriginEmbedderPolicy: false, // Allow embedding for OAuth
}));
// Compression middleware
app.use(compression());
// Cookie parser
app.use(cookieParser(configService.get<string>('COOKIE_SECRET')));
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Throw error for unknown properties
transform: true, // Transform payloads to DTO instances
disableErrorMessages: process.env.NODE_ENV === 'production',
}),
);
// Swagger documentation (development only)
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('SEO Image Renamer API')
.setDescription('AI-powered bulk image renaming SaaS API')
.setVersion('1.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'JWT',
description: 'Enter JWT token',
in: 'header',
},
'JWT-auth',
)
.addTag('Authentication', 'Google OAuth and JWT authentication')
.addTag('Users', 'User management and profile operations')
.addTag('Batches', 'Image batch processing')
.addTag('Images', 'Individual image operations')
.addTag('Payments', 'Stripe payment processing')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document, {
customSiteTitle: 'SEO Image Renamer API Documentation',
customfavIcon: '/favicon.ico',
customCss: '.swagger-ui .topbar { display: none }',
});
logger.log('Swagger documentation available at /api/docs');
}
// Start server
const port = configService.get<number>('PORT', 3001);
await app.listen(port);
logger.log(`🚀 SEO Image Renamer API running on port ${port}`);
logger.log(`📚 Environment: ${process.env.NODE_ENV || 'development'}`);
if (process.env.NODE_ENV !== 'production') {
logger.log(`📖 API Documentation: http://localhost:${port}/api/docs`);
}
}
bootstrap().catch((error) => {
Logger.error('Failed to start application', error);
process.exit(1);
});

View file

@ -1,22 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { HealthService } from './services/health.service';
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get()
async getHealth() {
return await this.healthService.checkHealth();
}
@Get('liveness')
getLiveness() {
return { status: 'alive' };
}
@Get('readiness')
async getReadiness() {
return await this.healthService.checkHealth();
}
}

View file

@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { MonitoringService } from './monitoring.service';
@Controller('metrics')
export class MetricsController {
constructor(private readonly monitoringService: MonitoringService) {}
@Get()
async getMetrics() {
return await this.monitoringService.getMetrics();
}
}

View file

@ -1,36 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MonitoringService } from './monitoring.service';
import { MetricsService } from './services/metrics.service';
import { TracingService } from './services/tracing.service';
import { HealthService } from './services/health.service';
import { LoggingService } from './services/logging.service';
import { HealthController } from './health.controller';
import { MetricsController } from './metrics.controller';
import { DatabaseModule } from '../database/database.module';
@Module({
imports: [
ConfigModule,
DatabaseModule,
],
controllers: [
HealthController,
MetricsController,
],
providers: [
MonitoringService,
MetricsService,
TracingService,
HealthService,
LoggingService,
],
exports: [
MonitoringService,
MetricsService,
TracingService,
HealthService,
LoggingService,
],
})
export class MonitoringModule {}

View file

@ -1,19 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class MonitoringService {
async getMetrics() {
return {
uptime: process.uptime(),
memory: process.memoryUsage(),
version: process.version,
};
}
async getHealth() {
return {
status: 'healthy',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -1,44 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
@Injectable()
export class HealthService {
constructor(private readonly prisma: PrismaService) {}
async checkHealth() {
const checks = await Promise.allSettled([
this.checkDatabase(),
this.checkRedis(),
this.checkStorage(),
]);
const health = {
status: 'healthy',
database: checks[0].status === 'fulfilled' ? 'healthy' : 'unhealthy',
redis: checks[1].status === 'fulfilled' ? 'healthy' : 'unhealthy',
storage: checks[2].status === 'fulfilled' ? 'healthy' : 'unhealthy',
timestamp: new Date().toISOString(),
};
if (checks.some(check => check.status === 'rejected')) {
health.status = 'unhealthy';
}
return health;
}
private async checkDatabase() {
await this.prisma.$queryRaw`SELECT 1`;
return { status: 'healthy' };
}
private async checkRedis() {
// TODO: Implement Redis health check
return { status: 'healthy' };
}
private async checkStorage() {
// TODO: Implement storage health check
return { status: 'healthy' };
}
}

View file

@ -1,26 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class LoggingService {
private readonly logger = new Logger(LoggingService.name);
log(message: string, context?: string) {
this.logger.log(message, context);
}
error(message: string, trace?: string, context?: string) {
this.logger.error(message, trace, context);
}
warn(message: string, context?: string) {
this.logger.warn(message, context);
}
debug(message: string, context?: string) {
this.logger.debug(message, context);
}
verbose(message: string, context?: string) {
this.logger.verbose(message, context);
}
}

View file

@ -1,103 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class MetricsService {
private readonly logger = new Logger(MetricsService.name);
private readonly metrics = new Map<string, number>();
constructor() {
this.logger.log('Metrics service initialized');
}
// HTTP Request metrics
recordHttpRequest(method: string, route: string, statusCode: number, duration: number) {
const key = `http_${method}_${route}_${statusCode}`;
this.incrementMetric(key);
this.setMetric(`${key}_duration`, duration);
}
// Business metrics
recordImageProcessed(status: 'success' | 'failed', userPlan: string) {
this.incrementMetric(`images_processed_${status}_${userPlan}`);
}
recordBatchCreated(userPlan: string) {
this.incrementMetric(`batches_created_${userPlan}`);
}
recordDownload(userPlan: string) {
this.incrementMetric(`downloads_${userPlan}`);
}
recordPayment(status: string, plan: string) {
this.incrementMetric(`payments_${status}_${plan}`);
}
recordUserRegistration(authProvider: string) {
this.incrementMetric(`users_registered_${authProvider}`);
}
// System metrics
setActiveConnections(count: number) {
this.setMetric('active_connections', count);
}
setQueueSize(queueName: string, size: number) {
this.setMetric(`queue_size_${queueName}`, size);
}
recordProcessingTime(operation: string, timeSeconds: number) {
this.setMetric(`processing_time_${operation}`, timeSeconds);
}
recordError(type: string, service: string) {
this.incrementMetric(`errors_${type}_${service}`);
}
// Resource metrics
updateSystemMetrics() {
try {
const memUsage = process.memoryUsage();
this.setMetric('memory_heap_used', memUsage.heapUsed);
this.setMetric('memory_heap_total', memUsage.heapTotal);
this.setMetric('memory_external', memUsage.external);
this.setMetric('uptime', process.uptime());
} catch (error) {
this.logger.error('Failed to update system metrics:', error);
}
}
// Get all metrics
async getMetrics(): Promise<Record<string, number>> {
this.updateSystemMetrics();
return Object.fromEntries(this.metrics);
}
// Reset all metrics (for testing)
resetMetrics() {
this.metrics.clear();
}
// Health check for metrics service
isHealthy(): boolean {
return true;
}
// Helper methods
private incrementMetric(key: string) {
const current = this.metrics.get(key) || 0;
this.metrics.set(key, current + 1);
}
private setMetric(key: string, value: number) {
this.metrics.set(key, value);
}
// Get metric summary for monitoring
getMetricsSummary() {
return {
totalMetrics: this.metrics.size,
lastUpdated: new Date().toISOString(),
};
}
}

View file

@ -1,24 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class TracingService {
async initializeTracing() {
// TODO: Initialize OpenTelemetry tracing
return { initialized: true };
}
async createSpan(name: string, operation: () => Promise<any>) {
// TODO: Create tracing span
const startTime = Date.now();
try {
const result = await operation();
const duration = Date.now() - startTime;
console.log(`Span: ${name} completed in ${duration}ms`);
return result;
} catch (error) {
const duration = Date.now() - startTime;
console.error(`Span: ${name} failed in ${duration}ms:`, error);
throw error;
}
}
}

View file

@ -1,30 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsUrl, IsNotEmpty } from 'class-validator';
import { Plan } from '@prisma/client';
export class CreateCheckoutSessionDto {
@ApiProperty({
description: 'The subscription plan to checkout',
enum: Plan,
example: Plan.PRO,
})
@IsEnum(Plan)
@IsNotEmpty()
plan: Plan;
@ApiProperty({
description: 'URL to redirect to after successful payment',
example: 'https://app.example.com/success',
})
@IsUrl()
@IsNotEmpty()
successUrl: string;
@ApiProperty({
description: 'URL to redirect to if payment is cancelled',
example: 'https://app.example.com/cancel',
})
@IsUrl()
@IsNotEmpty()
cancelUrl: string;
}

View file

@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUrl, IsNotEmpty } from 'class-validator';
export class CreatePortalSessionDto {
@ApiProperty({
description: 'URL to redirect to after portal session',
example: 'https://app.example.com/billing',
})
@IsUrl()
@IsNotEmpty()
returnUrl: string;
}

View file

@ -1,344 +0,0 @@
import {
IsString,
IsEnum,
IsInt,
IsOptional,
IsUUID,
IsObject,
Min,
IsDate,
Length
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Plan, PaymentStatus } from '@prisma/client';
import { Type } from 'class-transformer';
export interface PaymentMetadataInterface {
stripeCustomerId?: string;
subscriptionId?: string;
priceId?: string;
previousPlan?: Plan;
upgradeReason?: string;
discountCode?: string;
discountAmount?: number;
tax?: {
amount: number;
rate: number;
country: string;
};
}
export class CreatePaymentDto {
@ApiProperty({
description: 'ID of the user making the payment',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
userId: string;
@ApiProperty({
description: 'Plan being purchased',
enum: Plan,
example: Plan.PRO
})
@IsEnum(Plan)
plan: Plan;
@ApiProperty({
description: 'Payment amount in cents',
example: 2999,
minimum: 0
})
@IsInt()
@Min(0)
amount: number;
@ApiPropertyOptional({
description: 'Payment currency',
example: 'usd',
default: 'usd'
})
@IsOptional()
@IsString()
@Length(3, 3)
currency?: string;
@ApiPropertyOptional({
description: 'Stripe Checkout Session ID',
example: 'cs_test_123456789'
})
@IsOptional()
@IsString()
stripeSessionId?: string;
@ApiPropertyOptional({
description: 'Additional payment metadata'
})
@IsOptional()
@IsObject()
metadata?: PaymentMetadataInterface;
}
export class UpdatePaymentDto {
@ApiPropertyOptional({
description: 'Payment status',
enum: PaymentStatus
})
@IsOptional()
@IsEnum(PaymentStatus)
status?: PaymentStatus;
@ApiPropertyOptional({
description: 'Stripe Payment Intent ID',
example: 'pi_123456789'
})
@IsOptional()
@IsString()
stripePaymentId?: string;
@ApiPropertyOptional({
description: 'Additional payment metadata'
})
@IsOptional()
@IsObject()
metadata?: PaymentMetadataInterface;
}
export class PaymentResponseDto {
@ApiProperty({
description: 'Unique payment identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
id: string;
@ApiProperty({
description: 'ID of the user who made the payment',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
userId: string;
@ApiPropertyOptional({
description: 'Stripe Checkout Session ID',
example: 'cs_test_123456789'
})
@IsOptional()
@IsString()
stripeSessionId?: string;
@ApiPropertyOptional({
description: 'Stripe Payment Intent ID',
example: 'pi_123456789'
})
@IsOptional()
@IsString()
stripePaymentId?: string;
@ApiProperty({
description: 'Plan that was purchased',
enum: Plan
})
@IsEnum(Plan)
plan: Plan;
@ApiProperty({
description: 'Payment amount in cents',
example: 2999
})
@IsInt()
@Min(0)
amount: number;
@ApiProperty({
description: 'Payment currency',
example: 'usd'
})
@IsString()
currency: string;
@ApiProperty({
description: 'Current payment status',
enum: PaymentStatus
})
@IsEnum(PaymentStatus)
status: PaymentStatus;
@ApiPropertyOptional({
description: 'Additional payment metadata'
})
@IsOptional()
@IsObject()
metadata?: PaymentMetadataInterface;
@ApiProperty({
description: 'Payment creation timestamp'
})
@IsDate()
createdAt: Date;
@ApiProperty({
description: 'Payment last update timestamp'
})
@IsDate()
updatedAt: Date;
@ApiPropertyOptional({
description: 'Payment completion timestamp'
})
@IsOptional()
@IsDate()
paidAt?: Date;
}
export class StripeCheckoutSessionDto {
@ApiProperty({
description: 'Plan to purchase',
enum: Plan
})
@IsEnum(Plan)
plan: Plan;
@ApiPropertyOptional({
description: 'Success URL after payment',
example: 'https://app.example.com/success'
})
@IsOptional()
@IsString()
successUrl?: string;
@ApiPropertyOptional({
description: 'Cancel URL if payment is cancelled',
example: 'https://app.example.com/cancel'
})
@IsOptional()
@IsString()
cancelUrl?: string;
@ApiPropertyOptional({
description: 'Discount code to apply',
example: 'SUMMER2024'
})
@IsOptional()
@IsString()
discountCode?: string;
}
export class StripeCheckoutResponseDto {
@ApiProperty({
description: 'Stripe Checkout Session ID',
example: 'cs_test_123456789'
})
@IsString()
sessionId: string;
@ApiProperty({
description: 'Stripe Checkout URL',
example: 'https://checkout.stripe.com/pay/cs_test_123456789'
})
@IsString()
checkoutUrl: string;
@ApiProperty({
description: 'Payment record ID',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
paymentId: string;
}
export class PaymentStatsDto {
@ApiProperty({
description: 'Total payments made by user'
})
@IsInt()
@Min(0)
totalPayments: number;
@ApiProperty({
description: 'Total amount spent in cents'
})
@IsInt()
@Min(0)
totalAmountSpent: number;
@ApiProperty({
description: 'Current active plan'
})
@IsEnum(Plan)
currentPlan: Plan;
@ApiProperty({
description: 'Date of last successful payment'
})
@IsOptional()
@IsDate()
lastPaymentDate?: Date;
@ApiProperty({
description: 'Number of successful payments'
})
@IsInt()
@Min(0)
successfulPayments: number;
@ApiProperty({
description: 'Number of failed payments'
})
@IsInt()
@Min(0)
failedPayments: number;
}
// Plan pricing in cents
export const PLAN_PRICING = {
[Plan.BASIC]: 0, // Free plan
[Plan.PRO]: 2999, // $29.99
[Plan.MAX]: 4999, // $49.99
} as const;
// Helper function to get plan pricing
export function getPlanPrice(plan: Plan): number {
return PLAN_PRICING[plan];
}
// Helper function to format currency amount
export function formatCurrencyAmount(amountInCents: number, currency: string = 'usd'): string {
const amount = amountInCents / 100;
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
});
return formatter.format(amount);
}
// Helper function to validate plan upgrade
export function isValidPlanUpgrade(currentPlan: Plan, newPlan: Plan): boolean {
const planHierarchy = {
[Plan.BASIC]: 0,
[Plan.PRO]: 1,
[Plan.MAX]: 2,
};
return planHierarchy[newPlan] > planHierarchy[currentPlan];
}
// Helper function to calculate proration amount
export function calculateProrationAmount(
currentPlan: Plan,
newPlan: Plan,
daysRemaining: number,
totalDaysInPeriod: number = 30
): number {
if (!isValidPlanUpgrade(currentPlan, newPlan)) {
return 0;
}
const currentPlanPrice = getPlanPrice(currentPlan);
const newPlanPrice = getPlanPrice(newPlan);
const priceDifference = newPlanPrice - currentPlanPrice;
// Calculate prorated amount for remaining days
const prorationFactor = daysRemaining / totalDaysInPeriod;
return Math.round(priceDifference * prorationFactor);
}

View file

@ -1,297 +0,0 @@
import {
Controller,
Post,
Get,
Body,
Param,
UseGuards,
Request,
RawBodyRequest,
Req,
Headers,
HttpStatus,
HttpException,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/auth.guard';
import { PaymentsService } from './payments.service';
import { StripeService } from './services/stripe.service';
import { WebhookService } from './services/webhook.service';
import { CreateCheckoutSessionDto } from './dto/create-checkout-session.dto';
import { CreatePortalSessionDto } from './dto/create-portal-session.dto';
import { Plan } from '@prisma/client';
@ApiTags('payments')
@Controller('payments')
export class PaymentsController {
private readonly logger = new Logger(PaymentsController.name);
constructor(
private readonly paymentsService: PaymentsService,
private readonly stripeService: StripeService,
private readonly webhookService: WebhookService,
) {}
@Post('checkout')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create Stripe checkout session' })
@ApiResponse({ status: 201, description: 'Checkout session created successfully' })
async createCheckoutSession(
@Request() req: any,
@Body() createCheckoutSessionDto: CreateCheckoutSessionDto,
) {
try {
const userId = req.user.id;
const session = await this.stripeService.createCheckoutSession(
userId,
createCheckoutSessionDto.plan,
createCheckoutSessionDto.successUrl,
createCheckoutSessionDto.cancelUrl,
);
return {
sessionId: session.id,
url: session.url,
};
} catch (error) {
this.logger.error('Failed to create checkout session:', error);
throw new HttpException(
'Failed to create checkout session',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Post('portal')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create Stripe customer portal session' })
@ApiResponse({ status: 201, description: 'Portal session created successfully' })
async createPortalSession(
@Request() req: any,
@Body() createPortalSessionDto: CreatePortalSessionDto,
) {
try {
const userId = req.user.id;
const session = await this.stripeService.createPortalSession(
userId,
createPortalSessionDto.returnUrl,
);
return {
url: session.url,
};
} catch (error) {
this.logger.error('Failed to create portal session:', error);
throw new HttpException(
'Failed to create portal session',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('subscription')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get user subscription details' })
@ApiResponse({ status: 200, description: 'Subscription details retrieved successfully' })
async getSubscription(@Request() req: any) {
try {
const userId = req.user.id;
const subscription = await this.paymentsService.getUserSubscription(userId);
return subscription;
} catch (error) {
this.logger.error('Failed to get subscription:', error);
throw new HttpException(
'Failed to get subscription details',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('plans')
@ApiOperation({ summary: 'Get available subscription plans' })
@ApiResponse({ status: 200, description: 'Plans retrieved successfully' })
async getPlans() {
return {
plans: [
{
id: Plan.BASIC,
name: 'Basic',
price: 0,
currency: 'usd',
interval: 'month',
features: [
'50 images per month',
'AI-powered naming',
'Keyword enhancement',
'ZIP download',
],
quotaLimit: 50,
},
{
id: Plan.PRO,
name: 'Pro',
price: 900, // $9.00 in cents
currency: 'usd',
interval: 'month',
features: [
'500 images per month',
'AI-powered naming',
'Keyword enhancement',
'ZIP download',
'Priority support',
],
quotaLimit: 500,
},
{
id: Plan.MAX,
name: 'Max',
price: 1900, // $19.00 in cents
currency: 'usd',
interval: 'month',
features: [
'1000 images per month',
'AI-powered naming',
'Keyword enhancement',
'ZIP download',
'Priority support',
'Advanced analytics',
],
quotaLimit: 1000,
},
],
};
}
@Post('cancel-subscription')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Cancel user subscription' })
@ApiResponse({ status: 200, description: 'Subscription cancelled successfully' })
async cancelSubscription(@Request() req: any) {
try {
const userId = req.user.id;
await this.paymentsService.cancelSubscription(userId);
return { message: 'Subscription cancelled successfully' };
} catch (error) {
this.logger.error('Failed to cancel subscription:', error);
throw new HttpException(
'Failed to cancel subscription',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Post('reactivate-subscription')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Reactivate cancelled subscription' })
@ApiResponse({ status: 200, description: 'Subscription reactivated successfully' })
async reactivateSubscription(@Request() req: any) {
try {
const userId = req.user.id;
await this.paymentsService.reactivateSubscription(userId);
return { message: 'Subscription reactivated successfully' };
} catch (error) {
this.logger.error('Failed to reactivate subscription:', error);
throw new HttpException(
'Failed to reactivate subscription',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('payment-history')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get user payment history' })
@ApiResponse({ status: 200, description: 'Payment history retrieved successfully' })
async getPaymentHistory(@Request() req: any) {
try {
const userId = req.user.id;
const payments = await this.paymentsService.getPaymentHistory(userId);
return { payments };
} catch (error) {
this.logger.error('Failed to get payment history:', error);
throw new HttpException(
'Failed to get payment history',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Post('webhook')
@ApiOperation({ summary: 'Handle Stripe webhooks' })
@ApiResponse({ status: 200, description: 'Webhook processed successfully' })
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string,
) {
try {
await this.webhookService.handleWebhook(req.rawBody, signature);
return { received: true };
} catch (error) {
this.logger.error('Webhook processing failed:', error);
throw new HttpException(
'Webhook processing failed',
HttpStatus.BAD_REQUEST,
);
}
}
@Post('upgrade')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Upgrade subscription plan' })
@ApiResponse({ status: 200, description: 'Plan upgraded successfully' })
async upgradePlan(
@Request() req: any,
@Body() body: { plan: Plan; successUrl: string; cancelUrl: string },
) {
try {
const userId = req.user.id;
const session = await this.paymentsService.upgradePlan(
userId,
body.plan,
body.successUrl,
body.cancelUrl,
);
return {
sessionId: session.id,
url: session.url,
};
} catch (error) {
this.logger.error('Failed to upgrade plan:', error);
throw new HttpException(
'Failed to upgrade plan',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Post('downgrade')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Downgrade subscription plan' })
@ApiResponse({ status: 200, description: 'Plan downgraded successfully' })
async downgradePlan(
@Request() req: any,
@Body() body: { plan: Plan },
) {
try {
const userId = req.user.id;
await this.paymentsService.downgradePlan(userId, body.plan);
return { message: 'Plan downgraded successfully' };
} catch (error) {
this.logger.error('Failed to downgrade plan:', error);
throw new HttpException(
'Failed to downgrade plan',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View file

@ -1,28 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { StripeService } from './services/stripe.service';
// import { SubscriptionService } from './services/subscription.service';
import { WebhookService } from './services/webhook.service';
import { DatabaseModule } from '../database/database.module';
@Module({
imports: [
ConfigModule,
DatabaseModule,
],
controllers: [PaymentsController],
providers: [
PaymentsService,
StripeService,
// SubscriptionService,
WebhookService,
],
exports: [
PaymentsService,
StripeService,
// SubscriptionService,
],
})
export class PaymentsModule {}

View file

@ -1,274 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { PaymentsService } from './payments.service';
import { StripeService } from './services/stripe.service';
// import { SubscriptionService } from './services/subscription.service';
import { PaymentRepository } from '../database/repositories/payment.repository';
import { UserRepository } from '../database/repositories/user.repository';
import { Plan } from '@prisma/client';
describe('PaymentsService', () => {
let service: PaymentsService;
let stripeService: jest.Mocked<StripeService>;
// let subscriptionService: jest.Mocked<SubscriptionService>;
let paymentRepository: jest.Mocked<PaymentRepository>;
let userRepository: jest.Mocked<UserRepository>;
const mockUser = {
id: 'user-123',
email: 'test@example.com',
plan: Plan.BASIC,
quotaRemaining: 50,
quotaResetDate: new Date(),
isActive: true,
stripeCustomerId: 'cus_123',
createdAt: new Date(),
updatedAt: new Date(),
};
const mockSubscription = {
id: 'sub-123',
userId: 'user-123',
stripeSubscriptionId: 'sub_stripe_123',
stripeCustomerId: 'cus_123',
stripePriceId: 'price_123',
status: 'ACTIVE',
plan: Plan.PRO,
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(),
cancelAtPeriodEnd: false,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PaymentsService,
{
provide: StripeService,
useValue: {
createCheckoutSession: jest.fn(),
cancelSubscription: jest.fn(),
reactivateSubscription: jest.fn(),
scheduleSubscriptionChange: jest.fn(),
},
},
// {
// provide: SubscriptionService,
// useValue: {
// getActiveSubscription: jest.fn(),
// getCancelledSubscription: jest.fn(),
// markAsCancelled: jest.fn(),
// markAsActive: jest.fn(),
// create: jest.fn(),
// update: jest.fn(),
// findByStripeId: jest.fn(),
// markAsDeleted: jest.fn(),
// },
// },
{
provide: PaymentRepository,
useValue: {
findByUserId: jest.fn(),
create: jest.fn(),
},
},
{
provide: UserRepository,
useValue: {
findById: jest.fn(),
findByStripeCustomerId: jest.fn(),
updatePlan: jest.fn(),
resetQuota: jest.fn(),
},
},
],
}).compile();
service = module.get<PaymentsService>(PaymentsService);
stripeService = module.get(StripeService);
// subscriptionService = module.get(SubscriptionService);
paymentRepository = module.get(PaymentRepository);
userRepository = module.get(UserRepository);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getUserSubscription', () => {
it('should return user subscription details', async () => {
userRepository.findById.mockResolvedValue(mockUser);
// subscriptionService.getActiveSubscription.mockResolvedValue(mockSubscription);
paymentRepository.findByUserId.mockResolvedValue([]);
const result = await service.getUserSubscription('user-123');
expect(result).toEqual({
currentPlan: Plan.BASIC,
quotaRemaining: 50,
quotaLimit: 50,
quotaResetDate: mockUser.quotaResetDate,
subscription: null, // Temporarily disabled
recentPayments: [],
});
});
it('should throw NotFoundException if user not found', async () => {
userRepository.findById.mockResolvedValue(null);
await expect(service.getUserSubscription('user-123')).rejects.toThrow(
NotFoundException
);
});
});
describe('cancelSubscription', () => {
it('should throw error when subscription service is disabled', async () => {
await expect(service.cancelSubscription('user-123')).rejects.toThrow(
'Subscription service temporarily disabled'
);
});
});
describe('upgradePlan', () => {
it('should create checkout session for plan upgrade', async () => {
userRepository.findById.mockResolvedValue(mockUser);
const mockSession = { id: 'cs_123', url: 'https://checkout.stripe.com' };
stripeService.createCheckoutSession.mockResolvedValue(mockSession);
const result = await service.upgradePlan(
'user-123',
Plan.PRO,
'https://success.com',
'https://cancel.com'
);
expect(result).toEqual(mockSession);
expect(stripeService.createCheckoutSession).toHaveBeenCalledWith(
'user-123',
Plan.PRO,
'https://success.com',
'https://cancel.com',
true
);
});
it('should throw error for invalid upgrade path', async () => {
userRepository.findById.mockResolvedValue({ ...mockUser, plan: Plan.MAX });
await expect(
service.upgradePlan('user-123', Plan.PRO, 'success', 'cancel')
).rejects.toThrow('Invalid upgrade path');
});
});
describe('processSuccessfulPayment', () => {
it('should process successful payment and update user', async () => {
userRepository.findByStripeCustomerId.mockResolvedValue(mockUser);
paymentRepository.create.mockResolvedValue({} as any);
userRepository.updatePlan.mockResolvedValue({} as any);
userRepository.resetQuota.mockResolvedValue({} as any);
await service.processSuccessfulPayment(
'pi_123',
'cus_123',
900,
'usd',
Plan.PRO
);
expect(paymentRepository.create).toHaveBeenCalledWith({
userId: 'user-123',
stripePaymentIntentId: 'pi_123',
stripeCustomerId: 'cus_123',
amount: 900,
currency: 'usd',
status: 'succeeded',
planUpgrade: Plan.PRO,
});
expect(userRepository.updatePlan).toHaveBeenCalledWith('user-123', Plan.PRO);
expect(userRepository.resetQuota).toHaveBeenCalledWith('user-123', Plan.PRO);
});
it('should throw NotFoundException if user not found', async () => {
userRepository.findByStripeCustomerId.mockResolvedValue(null);
await expect(
service.processSuccessfulPayment('pi_123', 'cus_123', 900, 'usd', Plan.PRO)
).rejects.toThrow(NotFoundException);
});
});
// TODO: Re-enable tests when subscription service is restored
// describe('handleSubscriptionCreated', () => {
// const stripeSubscription = {
// id: 'sub_stripe_123',
// customer: 'cus_123',
// status: 'active',
// current_period_start: Math.floor(Date.now() / 1000),
// current_period_end: Math.floor(Date.now() / 1000) + 86400 * 30,
// items: {
// data: [
// {
// price: {
// id: 'price_pro_monthly',
// },
// },
// ],
// },
// };
// it('should create subscription and update user plan', async () => {
// userRepository.findByStripeCustomerId.mockResolvedValue(mockUser);
// subscriptionService.create.mockResolvedValue({} as any);
// userRepository.updatePlan.mockResolvedValue({} as any);
// userRepository.resetQuota.mockResolvedValue({} as any);
// await service.handleSubscriptionCreated(stripeSubscription);
// expect(subscriptionService.create).toHaveBeenCalledWith({
// userId: 'user-123',
// stripeSubscriptionId: 'sub_stripe_123',
// stripeCustomerId: 'cus_123',
// stripePriceId: 'price_pro_monthly',
// status: 'active',
// currentPeriodStart: expect.any(Date),
// currentPeriodEnd: expect.any(Date),
// plan: Plan.BASIC, // Default mapping
// });
// });
// });
describe('plan validation', () => {
it('should validate upgrade paths correctly', () => {
// Access private method for testing
const isValidUpgrade = (service as any).isValidUpgrade;
expect(isValidUpgrade(Plan.BASIC, Plan.PRO)).toBe(true);
expect(isValidUpgrade(Plan.PRO, Plan.MAX)).toBe(true);
expect(isValidUpgrade(Plan.PRO, Plan.BASIC)).toBe(false);
expect(isValidUpgrade(Plan.MAX, Plan.PRO)).toBe(false);
});
it('should validate downgrade paths correctly', () => {
const isValidDowngrade = (service as any).isValidDowngrade;
expect(isValidDowngrade(Plan.PRO, Plan.BASIC)).toBe(true);
expect(isValidDowngrade(Plan.MAX, Plan.PRO)).toBe(true);
expect(isValidDowngrade(Plan.BASIC, Plan.PRO)).toBe(false);
expect(isValidDowngrade(Plan.PRO, Plan.MAX)).toBe(false);
});
});
describe('quota limits', () => {
it('should return correct quota limits for each plan', () => {
const getQuotaLimit = (service as any).getQuotaLimit;
expect(getQuotaLimit(Plan.BASIC)).toBe(50);
expect(getQuotaLimit(Plan.PRO)).toBe(500);
expect(getQuotaLimit(Plan.MAX)).toBe(1000);
});
});
});

View file

@ -1,394 +0,0 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Plan } from '@prisma/client';
import { StripeService } from './services/stripe.service';
// import { SubscriptionService } from './services/subscription.service';
import { PaymentRepository } from '../database/repositories/payment.repository';
import { UserRepository } from '../database/repositories/user.repository';
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
constructor(
private readonly stripeService: StripeService,
// private readonly subscriptionService: SubscriptionService,
private readonly paymentRepository: PaymentRepository,
private readonly userRepository: UserRepository,
) {}
/**
* Get user subscription details
*/
async getUserSubscription(userId: string) {
try {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
// const subscription = await this.subscriptionService.getActiveSubscription(userId);
const paymentHistory = await this.paymentRepository.findByUserId(userId, {
take: 5,
orderBy: { createdAt: 'desc' }
}); // Last 5 payments
return {
currentPlan: user.plan,
quotaRemaining: user.quotaRemaining,
quotaLimit: this.getQuotaLimit(user.plan),
quotaResetDate: user.quotaResetDate,
subscription: null, // Temporarily disabled
recentPayments: paymentHistory.map(payment => ({
id: payment.id,
amount: payment.amount,
currency: payment.currency,
status: payment.status,
createdAt: payment.createdAt,
plan: payment.plan,
})),
};
} catch (error) {
this.logger.error(`Failed to get subscription for user ${userId}:`, error);
throw error;
}
}
/**
* Cancel user subscription
*/
async cancelSubscription(userId: string): Promise<void> {
try {
// TODO: Implement subscription cancellation logic without SubscriptionService
// const subscription = await this.subscriptionService.getActiveSubscription(userId);
// if (!subscription) {
// throw new NotFoundException('No active subscription found');
// }
// await this.stripeService.cancelSubscription(subscription.stripeSubscriptionId);
// await this.subscriptionService.markAsCancelled(subscription.id);
this.logger.log(`Subscription cancellation requested for user ${userId} (currently disabled)`);
throw new Error('Subscription service temporarily disabled');
} catch (error) {
this.logger.error(`Failed to cancel subscription for user ${userId}:`, error);
throw error;
}
}
/**
* Reactivate cancelled subscription
*/
async reactivateSubscription(userId: string): Promise<void> {
try {
// TODO: Implement subscription reactivation logic without SubscriptionService
// const subscription = await this.subscriptionService.getCancelledSubscription(userId);
// if (!subscription) {
// throw new NotFoundException('No cancelled subscription found');
// }
// await this.stripeService.reactivateSubscription(subscription.stripeSubscriptionId);
// await this.subscriptionService.markAsActive(subscription.id);
this.logger.log(`Subscription reactivation requested for user ${userId} (currently disabled)`);
throw new Error('Subscription service temporarily disabled');
} catch (error) {
this.logger.error(`Failed to reactivate subscription for user ${userId}:`, error);
throw error;
}
}
/**
* Get payment history for user
*/
async getPaymentHistory(userId: string, limit: number = 20) {
try {
return await this.paymentRepository.findByUserId(userId, { take: limit });
} catch (error) {
this.logger.error(`Failed to get payment history for user ${userId}:`, error);
throw error;
}
}
/**
* Upgrade user plan
*/
async upgradePlan(userId: string, newPlan: Plan, successUrl: string, cancelUrl: string) {
try {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
// Validate upgrade path
if (!this.isValidUpgrade(user.plan, newPlan)) {
throw new Error('Invalid upgrade path');
}
// Create checkout session for upgrade
const session = await this.stripeService.createCheckoutSession(
userId,
newPlan,
successUrl,
cancelUrl,
true, // isUpgrade
);
this.logger.log(`Plan upgrade initiated for user ${userId}: ${user.plan} -> ${newPlan}`);
return session;
} catch (error) {
this.logger.error(`Failed to upgrade plan for user ${userId}:`, error);
throw error;
}
}
/**
* Downgrade user plan
*/
async downgradePlan(userId: string, newPlan: Plan): Promise<void> {
try {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
// Validate downgrade path
if (!this.isValidDowngrade(user.plan, newPlan)) {
throw new Error('Invalid downgrade path');
}
// TODO: Implement downgrade logic without SubscriptionService
// For downgrades, we schedule the change for the next billing period
// const subscription = await this.subscriptionService.getActiveSubscription(userId);
// if (subscription) {
// await this.stripeService.scheduleSubscriptionChange(
// subscription.stripeSubscriptionId,
// newPlan,
// );
// }
// If downgrading to BASIC (free), cancel the subscription
if (newPlan === Plan.BASIC) {
await this.cancelSubscription(userId);
await this.userRepository.updatePlan(userId, Plan.BASIC);
await this.userRepository.resetQuota(userId);
}
this.logger.log(`Plan downgrade scheduled for user ${userId}: ${user.plan} -> ${newPlan}`);
} catch (error) {
this.logger.error(`Failed to downgrade plan for user ${userId}:`, error);
throw error;
}
}
/**
* Process successful payment
*/
async processSuccessfulPayment(
stripePaymentIntentId: string,
stripeCustomerId: string,
amount: number,
currency: string,
plan: Plan,
): Promise<void> {
try {
const user = await this.userRepository.findByStripeCustomerId(stripeCustomerId);
if (!user) {
throw new NotFoundException('User not found for Stripe customer');
}
// Record payment
await this.paymentRepository.create({
userId: user.id,
amount,
currency,
plan,
});
// Update user plan and quota
await this.userRepository.updatePlan(user.id, plan);
await this.userRepository.resetQuota(user.id);
this.logger.log(`Payment processed successfully for user ${user.id}, plan: ${plan}`);
} catch (error) {
this.logger.error('Failed to process successful payment:', error);
throw error;
}
}
/**
* Process failed payment
*/
async processFailedPayment(
stripePaymentIntentId: string,
stripeCustomerId: string,
amount: number,
currency: string,
): Promise<void> {
try {
const user = await this.userRepository.findByStripeCustomerId(stripeCustomerId);
if (!user) {
this.logger.warn(`User not found for failed payment: ${stripeCustomerId}`);
return;
}
// Record failed payment
await this.paymentRepository.create({
userId: user.id,
amount,
currency,
plan: Plan.BASIC, // Default for failed payment
});
this.logger.log(`Failed payment recorded for user ${user.id}`);
} catch (error) {
this.logger.error('Failed to process failed payment:', error);
throw error;
}
}
/**
* Handle subscription created
*/
async handleSubscriptionCreated(stripeSubscription: any): Promise<void> {
try {
const user = await this.userRepository.findByStripeCustomerId(stripeSubscription.customer);
if (!user) {
throw new NotFoundException('User not found for subscription');
}
const plan = this.getplanFromStripePrice(stripeSubscription.items.data[0].price.id);
// TODO: Store subscription data without SubscriptionService
// await this.subscriptionService.create({
// userId: user.id,
// stripeSubscriptionId: stripeSubscription.id,
// stripeCustomerId: stripeSubscription.customer,
// stripePriceId: stripeSubscription.items.data[0].price.id,
// status: stripeSubscription.status,
// currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
// currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
// plan,
// });
await this.userRepository.updatePlan(user.id, plan);
await this.userRepository.resetQuota(user.id);
this.logger.log(`Subscription created for user ${user.id}, plan: ${plan}`);
} catch (error) {
this.logger.error('Failed to handle subscription created:', error);
throw error;
}
}
/**
* Handle subscription updated
*/
async handleSubscriptionUpdated(stripeSubscription: any): Promise<void> {
try {
// TODO: Implement subscription update logic without SubscriptionService
// const subscription = await this.subscriptionService.findByStripeId(stripeSubscription.id);
// if (!subscription) {
// this.logger.warn(`Subscription not found: ${stripeSubscription.id}`);
// return;
// }
// const plan = this.getplanFromStripePrice(stripeSubscription.items.data[0].price.id);
// await this.subscriptionService.update(subscription.id, {
// status: stripeSubscription.status,
// currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
// currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
// cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
// plan,
// });
// // Update user plan if it changed
// if (subscription.plan !== plan) {
// await this.userRepository.updatePlan(subscription.userId, plan);
// await this.userRepository.resetQuota(subscription.userId, plan);
// }
this.logger.warn('Subscription update handling is temporarily disabled');
// this.logger.log(`Subscription updated for user ${subscription.userId}`);
} catch (error) {
this.logger.error('Failed to handle subscription updated:', error);
throw error;
}
}
/**
* Handle subscription deleted
*/
async handleSubscriptionDeleted(stripeSubscription: any): Promise<void> {
try {
// TODO: Implement subscription deletion logic without SubscriptionService
// const subscription = await this.subscriptionService.findByStripeId(stripeSubscription.id);
// if (!subscription) {
// this.logger.warn(`Subscription not found: ${stripeSubscription.id}`);
// return;
// }
// await this.subscriptionService.markAsDeleted(subscription.id);
// await this.userRepository.updatePlan(subscription.userId, Plan.BASIC);
// await this.userRepository.resetQuota(subscription.userId, Plan.BASIC);
this.logger.warn('Subscription deletion handling is temporarily disabled');
// this.logger.log(`Subscription deleted for user ${subscription.userId}`);
} catch (error) {
this.logger.error('Failed to handle subscription deleted:', error);
throw error;
}
}
/**
* Check if upgrade path is valid
*/
private isValidUpgrade(currentPlan: Plan, newPlan: Plan): boolean {
const planHierarchy = [Plan.BASIC, Plan.PRO, Plan.MAX];
const currentIndex = planHierarchy.indexOf(currentPlan);
const newIndex = planHierarchy.indexOf(newPlan);
return newIndex > currentIndex;
}
/**
* Check if downgrade path is valid
*/
private isValidDowngrade(currentPlan: Plan, newPlan: Plan): boolean {
const planHierarchy = [Plan.BASIC, Plan.PRO, Plan.MAX];
const currentIndex = planHierarchy.indexOf(currentPlan);
const newIndex = planHierarchy.indexOf(newPlan);
return newIndex < currentIndex;
}
/**
* Get quota limit for plan
*/
private getQuotaLimit(plan: Plan): number {
switch (plan) {
case Plan.PRO:
return 500;
case Plan.MAX:
return 1000;
default:
return 50;
}
}
/**
* Get plan from Stripe price ID
*/
private getplanFromStripePrice(priceId: string): Plan {
// Map Stripe price IDs to plans
// These would be configured based on your Stripe setup
const priceToplanMap: Record<string, Plan> = {
'price_pro_monthly': Plan.PRO,
'price_max_monthly': Plan.MAX,
};
return priceToplanMap[priceId] || Plan.BASIC;
}
}

View file

@ -1,318 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
import { Plan } from '@prisma/client';
import { UserRepository } from '../../database/repositories/user.repository';
@Injectable()
export class StripeService {
private readonly logger = new Logger(StripeService.name);
private readonly stripe: Stripe;
constructor(
private readonly configService: ConfigService,
private readonly userRepository: UserRepository,
) {
const apiKey = this.configService.get<string>('STRIPE_SECRET_KEY');
if (!apiKey) {
throw new Error('STRIPE_SECRET_KEY is required');
}
this.stripe = new Stripe(apiKey, {
apiVersion: '2023-10-16',
typescript: true,
});
}
/**
* Create checkout session for subscription
*/
async createCheckoutSession(
userId: string,
plan: Plan,
successUrl: string,
cancelUrl: string,
isUpgrade: boolean = false,
): Promise<Stripe.Checkout.Session> {
try {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new Error('User not found');
}
// Get or create Stripe customer
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await this.stripe.customers.create({
email: user.email,
metadata: {
userId: user.id,
},
});
customerId = customer.id;
await this.userRepository.updateStripeCustomerId(userId, customerId);
}
// Get price ID for plan
const priceId = this.getPriceIdForPlan(plan);
if (!priceId) {
throw new Error(`No price configured for plan: ${plan}`);
}
const sessionParams: Stripe.Checkout.SessionCreateParams = {
customer: customerId,
payment_method_types: ['card'],
mode: 'subscription',
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: successUrl,
cancel_url: cancelUrl,
allow_promotion_codes: true,
billing_address_collection: 'required',
metadata: {
userId,
plan,
isUpgrade: isUpgrade.toString(),
},
};
// For upgrades, prorate immediately
if (isUpgrade) {
sessionParams.subscription_data = {
proration_behavior: 'create_prorations',
};
}
const session = await this.stripe.checkout.sessions.create(sessionParams);
this.logger.log(`Checkout session created: ${session.id} for user ${userId}`);
return session;
} catch (error) {
this.logger.error('Failed to create checkout session:', error);
throw error;
}
}
/**
* Create customer portal session
*/
async createPortalSession(userId: string, returnUrl: string): Promise<Stripe.BillingPortal.Session> {
try {
const user = await this.userRepository.findById(userId);
if (!user || !user.stripeCustomerId) {
throw new Error('User or Stripe customer not found');
}
const session = await this.stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: returnUrl,
});
this.logger.log(`Portal session created for user ${userId}`);
return session;
} catch (error) {
this.logger.error('Failed to create portal session:', error);
throw error;
}
}
/**
* Cancel subscription
*/
async cancelSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
try {
const subscription = await this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
this.logger.log(`Subscription cancelled: ${subscriptionId}`);
return subscription;
} catch (error) {
this.logger.error('Failed to cancel subscription:', error);
throw error;
}
}
/**
* Reactivate subscription
*/
async reactivateSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
try {
const subscription = await this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
this.logger.log(`Subscription reactivated: ${subscriptionId}`);
return subscription;
} catch (error) {
this.logger.error('Failed to reactivate subscription:', error);
throw error;
}
}
/**
* Schedule subscription change for next billing period
*/
async scheduleSubscriptionChange(subscriptionId: string, newPlan: Plan): Promise<void> {
try {
const newPriceId = this.getPriceIdForPlan(newPlan);
if (!newPriceId) {
throw new Error(`No price configured for plan: ${newPlan}`);
}
// Get current subscription
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
// Schedule the modification for the next billing period
await this.stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscription.items.data[0].id,
price: newPriceId,
},
],
proration_behavior: 'none', // Don't prorate downgrades
billing_cycle_anchor: 'unchanged',
});
this.logger.log(`Subscription change scheduled: ${subscriptionId} to ${newPlan}`);
} catch (error) {
this.logger.error('Failed to schedule subscription change:', error);
throw error;
}
}
/**
* Get subscription by ID
*/
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
try {
return await this.stripe.subscriptions.retrieve(subscriptionId);
} catch (error) {
this.logger.error('Failed to get subscription:', error);
throw error;
}
}
/**
* Construct webhook event
*/
constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event {
const webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET');
if (!webhookSecret) {
throw new Error('STRIPE_WEBHOOK_SECRET is required');
}
try {
return this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
} catch (error) {
this.logger.error('Failed to construct webhook event:', error);
throw error;
}
}
/**
* Create refund
*/
async createRefund(paymentIntentId: string, amount?: number): Promise<Stripe.Refund> {
try {
const refund = await this.stripe.refunds.create({
payment_intent: paymentIntentId,
amount, // If not provided, refunds the full amount
});
this.logger.log(`Refund created: ${refund.id} for payment ${paymentIntentId}`);
return refund;
} catch (error) {
this.logger.error('Failed to create refund:', error);
throw error;
}
}
/**
* Get customer payment methods
*/
async getCustomerPaymentMethods(customerId: string): Promise<Stripe.PaymentMethod[]> {
try {
const paymentMethods = await this.stripe.paymentMethods.list({
customer: customerId,
type: 'card',
});
return paymentMethods.data;
} catch (error) {
this.logger.error('Failed to get customer payment methods:', error);
throw error;
}
}
/**
* Update customer
*/
async updateCustomer(customerId: string, params: Stripe.CustomerUpdateParams): Promise<Stripe.Customer> {
try {
const customer = await this.stripe.customers.update(customerId, params);
this.logger.log(`Customer updated: ${customerId}`);
return customer;
} catch (error) {
this.logger.error('Failed to update customer:', error);
throw error;
}
}
/**
* Get invoice by subscription
*/
async getLatestInvoice(subscriptionId: string): Promise<Stripe.Invoice | null> {
try {
const invoices = await this.stripe.invoices.list({
subscription: subscriptionId,
limit: 1,
});
return invoices.data[0] || null;
} catch (error) {
this.logger.error('Failed to get latest invoice:', error);
throw error;
}
}
/**
* Get price ID for plan
*/
private getPriceIdForPlan(plan: Plan): string | null {
const priceMap: Record<Plan, string> = {
[Plan.BASIC]: '', // No price for free plan
[Plan.PRO]: this.configService.get<string>('STRIPE_PRO_PRICE_ID') || 'price_pro_monthly',
[Plan.MAX]: this.configService.get<string>('STRIPE_MAX_PRICE_ID') || 'price_max_monthly',
};
return priceMap[plan] || null;
}
/**
* Create usage record for metered billing (if needed in future)
*/
async createUsageRecord(subscriptionItemId: string, quantity: number): Promise<Stripe.UsageRecord> {
try {
const usageRecord = await this.stripe.subscriptionItems.createUsageRecord(
subscriptionItemId,
{
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
},
);
this.logger.log(`Usage record created: ${quantity} units for ${subscriptionItemId}`);
return usageRecord;
} catch (error) {
this.logger.error('Failed to create usage record:', error);
throw error;
}
}
}

View file

@ -1,393 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Plan, SubscriptionStatus } from '@prisma/client';
import { PrismaService } from '../../database/prisma.service';
export interface CreateSubscriptionData {
userId: string;
stripeSubscriptionId: string;
stripeCustomerId: string;
stripePriceId: string;
status: string;
currentPeriodStart: Date;
currentPeriodEnd: Date;
plan: Plan;
}
export interface UpdateSubscriptionData {
status?: string;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
cancelAtPeriodEnd?: boolean;
plan?: Plan;
}
@Injectable()
export class SubscriptionService {
private readonly logger = new Logger(SubscriptionService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create new subscription
*/
async create(data: CreateSubscriptionData) {
try {
return await this.prisma.subscription.create({
data: {
userId: data.userId,
stripeSubscriptionId: data.stripeSubscriptionId,
stripeCustomerId: data.stripeCustomerId,
stripePriceId: data.stripePriceId,
status: this.mapStripeStatusToEnum(data.status),
currentPeriodStart: data.currentPeriodStart,
currentPeriodEnd: data.currentPeriodEnd,
plan: data.plan,
},
});
} catch (error) {
this.logger.error('Failed to create subscription:', error);
throw error;
}
}
/**
* Update subscription
*/
async update(subscriptionId: string, data: UpdateSubscriptionData) {
try {
const updateData: any = {};
if (data.status) {
updateData.status = this.mapStripeStatusToEnum(data.status);
}
if (data.currentPeriodStart) {
updateData.currentPeriodStart = data.currentPeriodStart;
}
if (data.currentPeriodEnd) {
updateData.currentPeriodEnd = data.currentPeriodEnd;
}
if (data.cancelAtPeriodEnd !== undefined) {
updateData.cancelAtPeriodEnd = data.cancelAtPeriodEnd;
}
if (data.plan) {
updateData.plan = data.plan;
}
return await this.prisma.subscription.update({
where: { id: subscriptionId },
data: updateData,
});
} catch (error) {
this.logger.error('Failed to update subscription:', error);
throw error;
}
}
/**
* Get active subscription for user
*/
async getActiveSubscription(userId: string) {
try {
return await this.prisma.subscription.findFirst({
where: {
userId,
status: {
in: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIALING],
},
},
orderBy: {
createdAt: 'desc',
},
});
} catch (error) {
this.logger.error('Failed to get active subscription:', error);
throw error;
}
}
/**
* Get cancelled subscription for user
*/
async getCancelledSubscription(userId: string) {
try {
return await this.prisma.subscription.findFirst({
where: {
userId,
status: SubscriptionStatus.CANCELED,
cancelAtPeriodEnd: true,
currentPeriodEnd: {
gte: new Date(), // Still within the paid period
},
},
orderBy: {
createdAt: 'desc',
},
});
} catch (error) {
this.logger.error('Failed to get cancelled subscription:', error);
throw error;
}
}
/**
* Find subscription by Stripe ID
*/
async findByStripeId(stripeSubscriptionId: string) {
try {
return await this.prisma.subscription.findUnique({
where: {
stripeSubscriptionId,
},
});
} catch (error) {
this.logger.error('Failed to find subscription by Stripe ID:', error);
throw error;
}
}
/**
* Mark subscription as cancelled
*/
async markAsCancelled(subscriptionId: string) {
try {
return await this.prisma.subscription.update({
where: { id: subscriptionId },
data: {
status: SubscriptionStatus.CANCELED,
cancelAtPeriodEnd: true,
},
});
} catch (error) {
this.logger.error('Failed to mark subscription as cancelled:', error);
throw error;
}
}
/**
* Mark subscription as active
*/
async markAsActive(subscriptionId: string) {
try {
return await this.prisma.subscription.update({
where: { id: subscriptionId },
data: {
status: SubscriptionStatus.ACTIVE,
cancelAtPeriodEnd: false,
},
});
} catch (error) {
this.logger.error('Failed to mark subscription as active:', error);
throw error;
}
}
/**
* Mark subscription as deleted
*/
async markAsDeleted(subscriptionId: string) {
try {
return await this.prisma.subscription.update({
where: { id: subscriptionId },
data: {
status: SubscriptionStatus.CANCELED,
cancelAtPeriodEnd: false,
},
});
} catch (error) {
this.logger.error('Failed to mark subscription as deleted:', error);
throw error;
}
}
/**
* Get all subscriptions for user
*/
async getAllForUser(userId: string) {
try {
return await this.prisma.subscription.findMany({
where: { userId },
orderBy: {
createdAt: 'desc',
},
});
} catch (error) {
this.logger.error('Failed to get all subscriptions for user:', error);
throw error;
}
}
/**
* Get expiring subscriptions (for reminders)
*/
async getExpiringSubscriptions(days: number = 3) {
try {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + days);
return await this.prisma.subscription.findMany({
where: {
status: SubscriptionStatus.ACTIVE,
currentPeriodEnd: {
lte: expirationDate,
gte: new Date(),
},
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
} catch (error) {
this.logger.error('Failed to get expiring subscriptions:', error);
throw error;
}
}
/**
* Get subscription analytics
*/
async getAnalytics(startDate?: Date, endDate?: Date) {
try {
const whereClause: any = {};
if (startDate && endDate) {
whereClause.createdAt = {
gte: startDate,
lte: endDate,
};
}
const [
totalSubscriptions,
activeSubscriptions,
cancelledSubscriptions,
planDistribution,
revenueByPlan,
] = await Promise.all([
// Total subscriptions
this.prisma.subscription.count({ where: whereClause }),
// Active subscriptions
this.prisma.subscription.count({
where: {
...whereClause,
status: {
in: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIALING],
},
},
}),
// Cancelled subscriptions
this.prisma.subscription.count({
where: {
...whereClause,
status: SubscriptionStatus.CANCELED,
},
}),
// Plan distribution
this.prisma.subscription.groupBy({
by: ['plan'],
where: {
...whereClause,
status: {
in: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIALING],
},
},
_count: {
id: true,
},
}),
// Revenue by plan (from payments)
this.prisma.payment.groupBy({
by: ['planUpgrade'],
where: {
...whereClause,
status: 'succeeded',
planUpgrade: {
not: null,
},
},
_sum: {
amount: true,
},
}),
]);
return {
totalSubscriptions,
activeSubscriptions,
cancelledSubscriptions,
churnRate: totalSubscriptions > 0 ? (cancelledSubscriptions / totalSubscriptions) * 100 : 0,
planDistribution: planDistribution.map(item => ({
plan: item.plan,
count: item._count.id,
})),
revenueByPlan: revenueByPlan.map(item => ({
plan: item.planUpgrade,
revenue: item._sum.amount || 0,
})),
};
} catch (error) {
this.logger.error('Failed to get subscription analytics:', error);
throw error;
}
}
/**
* Clean up expired subscriptions
*/
async cleanupExpiredSubscriptions() {
try {
const expiredDate = new Date();
expiredDate.setDate(expiredDate.getDate() - 30); // 30 days grace period
const result = await this.prisma.subscription.updateMany({
where: {
status: SubscriptionStatus.CANCELED,
currentPeriodEnd: {
lt: expiredDate,
},
},
data: {
status: SubscriptionStatus.CANCELED,
},
});
this.logger.log(`Cleaned up ${result.count} expired subscriptions`);
return result.count;
} catch (error) {
this.logger.error('Failed to clean up expired subscriptions:', error);
throw error;
}
}
/**
* Map Stripe status to Prisma enum
*/
private mapStripeStatusToEnum(stripeStatus: string): SubscriptionStatus {
switch (stripeStatus) {
case 'active':
return SubscriptionStatus.ACTIVE;
case 'canceled':
return SubscriptionStatus.CANCELED;
case 'incomplete':
return SubscriptionStatus.INCOMPLETE;
case 'incomplete_expired':
return SubscriptionStatus.INCOMPLETE_EXPIRED;
case 'past_due':
return SubscriptionStatus.PAST_DUE;
case 'trialing':
return SubscriptionStatus.TRIALING;
case 'unpaid':
return SubscriptionStatus.UNPAID;
default:
return SubscriptionStatus.INCOMPLETE;
}
}
}

View file

@ -1,280 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { StripeService } from './stripe.service';
import { PaymentsService } from '../payments.service';
import { Plan } from '@prisma/client';
@Injectable()
export class WebhookService {
private readonly logger = new Logger(WebhookService.name);
constructor(
private readonly stripeService: StripeService,
private readonly paymentsService: PaymentsService,
) {}
/**
* Handle Stripe webhook
*/
async handleWebhook(payload: Buffer, signature: string): Promise<void> {
try {
const event = this.stripeService.constructWebhookEvent(payload, signature);
this.logger.log(`Received webhook: ${event.type}`);
switch (event.type) {
case 'payment_intent.succeeded':
await this.handlePaymentIntentSucceeded(event.data.object as Stripe.PaymentIntent);
break;
case 'payment_intent.payment_failed':
await this.handlePaymentIntentFailed(event.data.object as Stripe.PaymentIntent);
break;
case 'customer.subscription.created':
await this.handleSubscriptionCreated(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_succeeded':
await this.handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await this.handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
break;
case 'checkout.session.completed':
await this.handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'customer.created':
await this.handleCustomerCreated(event.data.object as Stripe.Customer);
break;
case 'customer.updated':
await this.handleCustomerUpdated(event.data.object as Stripe.Customer);
break;
case 'customer.deleted':
await this.handleCustomerDeleted(event.data.object as Stripe.Customer);
break;
default:
this.logger.warn(`Unhandled webhook event type: ${event.type}`);
}
this.logger.log(`Successfully processed webhook: ${event.type}`);
} catch (error) {
this.logger.error('Failed to handle webhook:', error);
throw error;
}
}
/**
* Handle payment intent succeeded
*/
private async handlePaymentIntentSucceeded(paymentIntent: Stripe.PaymentIntent): Promise<void> {
try {
const customerId = paymentIntent.customer as string;
const amount = paymentIntent.amount;
const currency = paymentIntent.currency;
// Extract plan from metadata
const plan = paymentIntent.metadata.plan as Plan || Plan.BASIC;
await this.paymentsService.processSuccessfulPayment(
paymentIntent.id,
customerId,
amount,
currency,
plan,
);
this.logger.log(`Payment succeeded: ${paymentIntent.id}`);
} catch (error) {
this.logger.error('Failed to handle payment intent succeeded:', error);
throw error;
}
}
/**
* Handle payment intent failed
*/
private async handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent): Promise<void> {
try {
const customerId = paymentIntent.customer as string;
const amount = paymentIntent.amount;
const currency = paymentIntent.currency;
await this.paymentsService.processFailedPayment(
paymentIntent.id,
customerId,
amount,
currency,
);
this.logger.log(`Payment failed: ${paymentIntent.id}`);
} catch (error) {
this.logger.error('Failed to handle payment intent failed:', error);
throw error;
}
}
/**
* Handle subscription created
*/
private async handleSubscriptionCreated(subscription: Stripe.Subscription): Promise<void> {
try {
await this.paymentsService.handleSubscriptionCreated(subscription);
this.logger.log(`Subscription created: ${subscription.id}`);
} catch (error) {
this.logger.error('Failed to handle subscription created:', error);
throw error;
}
}
/**
* Handle subscription updated
*/
private async handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
try {
await this.paymentsService.handleSubscriptionUpdated(subscription);
this.logger.log(`Subscription updated: ${subscription.id}`);
} catch (error) {
this.logger.error('Failed to handle subscription updated:', error);
throw error;
}
}
/**
* Handle subscription deleted
*/
private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
try {
await this.paymentsService.handleSubscriptionDeleted(subscription);
this.logger.log(`Subscription deleted: ${subscription.id}`);
} catch (error) {
this.logger.error('Failed to handle subscription deleted:', error);
throw error;
}
}
/**
* Handle invoice payment succeeded
*/
private async handleInvoicePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
try {
// This typically happens for recurring payments
if (invoice.subscription) {
const subscription = await this.stripeService.getSubscription(invoice.subscription as string);
await this.paymentsService.handleSubscriptionUpdated(subscription);
}
this.logger.log(`Invoice payment succeeded: ${invoice.id}`);
} catch (error) {
this.logger.error('Failed to handle invoice payment succeeded:', error);
throw error;
}
}
/**
* Handle invoice payment failed
*/
private async handleInvoicePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
try {
// Handle failed recurring payment
// You might want to send notifications, attempt retries, etc.
this.logger.warn(`Invoice payment failed: ${invoice.id}`);
// If this is a subscription invoice, you might want to:
// 1. Send notification to user
// 2. Mark subscription as past due
// 3. Implement dunning management
} catch (error) {
this.logger.error('Failed to handle invoice payment failed:', error);
throw error;
}
}
/**
* Handle checkout session completed
*/
private async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session): Promise<void> {
try {
// This is called when a checkout session is successfully completed
// The actual payment processing is handled by payment_intent.succeeded
this.logger.log(`Checkout session completed: ${session.id}`);
// You might want to:
// 1. Send confirmation email
// 2. Update user preferences
// 3. Track conversion metrics
} catch (error) {
this.logger.error('Failed to handle checkout session completed:', error);
throw error;
}
}
/**
* Handle customer created
*/
private async handleCustomerCreated(customer: Stripe.Customer): Promise<void> {
try {
this.logger.log(`Customer created: ${customer.id}`);
// Customer is usually created from our app, so no additional action needed
// But you might want to sync additional data or send welcome emails
} catch (error) {
this.logger.error('Failed to handle customer created:', error);
throw error;
}
}
/**
* Handle customer updated
*/
private async handleCustomerUpdated(customer: Stripe.Customer): Promise<void> {
try {
this.logger.log(`Customer updated: ${customer.id}`);
// You might want to sync customer data back to your database
// For example, if they update their email or billing address
} catch (error) {
this.logger.error('Failed to handle customer updated:', error);
throw error;
}
}
/**
* Handle customer deleted
*/
private async handleCustomerDeleted(customer: Stripe.Customer): Promise<void> {
try {
this.logger.log(`Customer deleted: ${customer.id}`);
// Handle customer deletion
// You might want to:
// 1. Clean up related data
// 2. Cancel active subscriptions
// 3. Update user records
} catch (error) {
this.logger.error('Failed to handle customer deleted:', error);
throw error;
}
}
}

View file

@ -1,249 +0,0 @@
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { BatchProcessingJobData, JobProgress } from '../queue.service';
@Processor('batch-processing')
export class BatchProcessingProcessor extends WorkerHost {
private readonly logger = new Logger(BatchProcessingProcessor.name);
async process(job: Job<BatchProcessingJobData>): Promise<any> {
const { batchId, userId, imageIds, keywords } = job.data;
this.logger.log(`Processing batch: ${batchId} with ${imageIds.length} images`);
try {
// Update progress - Starting
await this.updateProgress(job, {
percentage: 0,
processedCount: 0,
totalCount: imageIds.length,
status: 'starting',
});
let processedCount = 0;
const results = [];
// Process each image in the batch
for (const imageId of imageIds) {
try {
this.logger.log(`Processing image ${processedCount + 1}/${imageIds.length}: ${imageId}`);
// Update progress
const percentage = Math.round((processedCount / imageIds.length) * 90); // Reserve 10% for finalization
await this.updateProgress(job, {
percentage,
currentImage: imageId,
processedCount,
totalCount: imageIds.length,
status: 'processing-images',
});
// Simulate individual image processing
await this.processIndividualImage(imageId, batchId, keywords);
processedCount++;
results.push({
imageId,
success: true,
processedAt: new Date(),
});
} catch (error) {
this.logger.error(`Failed to process image in batch: ${imageId}`, error.stack);
results.push({
imageId,
success: false,
error: error.message,
processedAt: new Date(),
});
}
}
// Finalize batch processing (90-100%)
await this.updateProgress(job, {
percentage: 95,
processedCount,
totalCount: imageIds.length,
status: 'finalizing',
});
// Update batch status in database
await this.finalizeBatchProcessing(batchId, results);
// Complete processing
await this.updateProgress(job, {
percentage: 100,
processedCount,
totalCount: imageIds.length,
status: 'completed',
});
this.logger.log(`Completed batch processing: ${batchId}`);
return {
batchId,
totalImages: imageIds.length,
successfulImages: results.filter(r => r.success).length,
failedImages: results.filter(r => !r.success).length,
processingTime: Date.now() - job.timestamp,
results,
};
} catch (error) {
this.logger.error(`Failed to process batch: ${batchId}`, error.stack);
// Update progress - Failed
await this.updateProgress(job, {
percentage: 0,
processedCount: 0,
totalCount: imageIds.length,
status: 'failed',
});
// Mark batch as failed in database
await this.markBatchAsFailed(batchId, error.message);
throw error;
}
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.log(`Batch processing completed: ${job.id}`);
}
@OnWorkerEvent('failed')
onFailed(job: Job, err: Error) {
this.logger.error(`Batch processing failed: ${job.id}`, err.stack);
}
@OnWorkerEvent('progress')
onProgress(job: Job, progress: JobProgress) {
this.logger.debug(`Batch processing progress: ${job.id} - ${progress.percentage}%`);
}
/**
* Update job progress
*/
private async updateProgress(job: Job, progress: JobProgress): Promise<void> {
await job.updateProgress(progress);
}
/**
* Process an individual image within the batch
* @param imageId Image ID to process
* @param batchId Batch ID
* @param keywords Keywords for processing
*/
private async processIndividualImage(
imageId: string,
batchId: string,
keywords?: string[]
): Promise<void> {
// Simulate individual image processing time
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000));
// TODO: Implement actual image processing logic
// This would typically:
// 1. Fetch image from storage
// 2. Perform AI vision analysis
// 3. Generate SEO filename
// 4. Update image record in database
this.logger.debug(`Processed individual image: ${imageId}`);
}
/**
* Finalize batch processing and update database
* @param batchId Batch ID
* @param results Processing results for all images
*/
private async finalizeBatchProcessing(batchId: string, results: any[]): Promise<void> {
try {
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
// TODO: Update batch record in database
// This would typically:
// 1. Update batch status to DONE or ERROR
// 2. Set processedImages and failedImages counts
// 3. Set completedAt timestamp
// 4. Update any batch metadata
this.logger.log(`Finalized batch ${batchId}: ${successCount} successful, ${failCount} failed`);
// Simulate database update
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
this.logger.error(`Failed to finalize batch: ${batchId}`, error.stack);
throw error;
}
}
/**
* Mark batch as failed in database
* @param batchId Batch ID
* @param errorMessage Error message
*/
private async markBatchAsFailed(batchId: string, errorMessage: string): Promise<void> {
try {
// TODO: Update batch record in database
// This would typically:
// 1. Update batch status to ERROR
// 2. Set error message in metadata
// 3. Set completedAt timestamp
this.logger.log(`Marked batch as failed: ${batchId}`);
// Simulate database update
await new Promise(resolve => setTimeout(resolve, 200));
} catch (error) {
this.logger.error(`Failed to mark batch as failed: ${batchId}`, error.stack);
}
}
/**
* Calculate batch processing statistics
* @param results Processing results
* @returns Statistics object
*/
private calculateBatchStats(results: any[]) {
const total = results.length;
const successful = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success).length;
const successRate = total > 0 ? (successful / total) * 100 : 0;
return {
total,
successful,
failed,
successRate: Math.round(successRate * 100) / 100,
};
}
/**
* Send batch completion notification
* @param batchId Batch ID
* @param userId User ID
* @param stats Batch statistics
*/
private async sendBatchCompletionNotification(
batchId: string,
userId: string,
stats: any
): Promise<void> {
try {
// TODO: Implement notification system
// This could send email, push notification, or WebSocket event
this.logger.log(`Sent batch completion notification: ${batchId} to user: ${userId}`);
} catch (error) {
this.logger.error(`Failed to send batch completion notification: ${batchId}`, error.stack);
// Don't throw error - notification failure shouldn't fail the job
}
}
}

View file

@ -1,200 +0,0 @@
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { ImageProcessingJobData, JobProgress } from '../queue.service';
@Processor('image-processing')
export class ImageProcessingProcessor extends WorkerHost {
private readonly logger = new Logger(ImageProcessingProcessor.name);
async process(job: Job<ImageProcessingJobData>): Promise<any> {
const { imageId, batchId, s3Key, originalName, userId, keywords } = job.data;
this.logger.log(`Processing image: ${imageId} from batch: ${batchId}`);
try {
// Update progress - Starting
await this.updateProgress(job, {
percentage: 0,
currentImage: originalName,
processedCount: 0,
totalCount: 1,
status: 'starting',
});
// Step 1: Download image from storage (10%)
await this.updateProgress(job, {
percentage: 10,
currentImage: originalName,
processedCount: 0,
totalCount: 1,
status: 'downloading',
});
// TODO: Implement actual image download from storage
// Step 2: AI Vision Analysis (50%)
await this.updateProgress(job, {
percentage: 30,
currentImage: originalName,
processedCount: 0,
totalCount: 1,
status: 'analyzing',
});
const visionTags = await this.performVisionAnalysis(s3Key, keywords);
// Step 3: Generate SEO filename (70%)
await this.updateProgress(job, {
percentage: 70,
currentImage: originalName,
processedCount: 0,
totalCount: 1,
status: 'generating-filename',
});
const proposedName = await this.generateSeoFilename(visionTags, originalName, keywords);
// Step 4: Update database (90%)
await this.updateProgress(job, {
percentage: 90,
currentImage: originalName,
processedCount: 0,
totalCount: 1,
status: 'updating-database',
});
// TODO: Update image record in database with vision tags and proposed name
// Step 5: Complete (100%)
await this.updateProgress(job, {
percentage: 100,
currentImage: originalName,
processedCount: 1,
totalCount: 1,
status: 'completed',
});
this.logger.log(`Completed processing image: ${imageId}`);
return {
imageId,
success: true,
proposedName,
visionTags,
processingTime: Date.now() - job.timestamp,
};
} catch (error) {
this.logger.error(`Failed to process image: ${imageId}`, error.stack);
// Update progress - Failed
await this.updateProgress(job, {
percentage: 0,
currentImage: originalName,
processedCount: 0,
totalCount: 1,
status: 'failed',
});
throw error;
}
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.log(`Image processing completed: ${job.id}`);
}
@OnWorkerEvent('failed')
onFailed(job: Job, err: Error) {
this.logger.error(`Image processing failed: ${job.id}`, err.stack);
}
@OnWorkerEvent('progress')
onProgress(job: Job, progress: JobProgress) {
this.logger.debug(`Image processing progress: ${job.id} - ${progress.percentage}%`);
}
/**
* Update job progress
*/
private async updateProgress(job: Job, progress: JobProgress): Promise<void> {
await job.updateProgress(progress);
}
/**
* Perform AI vision analysis on the image
* @param s3Key Storage key for the image
* @param keywords Additional keywords for context
* @returns Vision analysis results
*/
private async performVisionAnalysis(s3Key: string, keywords?: string[]): Promise<any> {
// Simulate AI processing time
await new Promise(resolve => setTimeout(resolve, 2000));
// TODO: Implement actual AI vision analysis
// This would integrate with OpenAI GPT-4 Vision or similar service
// Mock response for now
return {
objects: ['modern', 'kitchen', 'appliances', 'interior'],
colors: ['white', 'stainless-steel', 'gray'],
scene: 'modern kitchen interior',
description: 'A modern kitchen with stainless steel appliances and white cabinets',
confidence: 0.92,
aiModel: 'gpt-4-vision',
processingTime: 2.1,
keywords: keywords || [],
};
}
/**
* Generate SEO-friendly filename from vision analysis
* @param visionTags AI vision analysis results
* @param originalName Original filename
* @param keywords Additional keywords
* @returns SEO-optimized filename
*/
private async generateSeoFilename(
visionTags: any,
originalName: string,
keywords?: string[]
): Promise<string> {
try {
// Combine AI-detected objects with user keywords
const allKeywords = [
...(visionTags.objects || []),
...(keywords || []),
...(visionTags.colors || []).slice(0, 2), // Limit colors
];
// Remove duplicates and filter out common words
const filteredKeywords = [...new Set(allKeywords)]
.filter(keyword => keyword.length > 2)
.filter(keyword => !['the', 'and', 'with', 'for', 'are', 'was'].includes(keyword.toLowerCase()))
.slice(0, 5); // Limit to 5 keywords for filename
// Create SEO-friendly filename
let filename = filteredKeywords
.join('-')
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.substring(0, 80); // Limit length
// Get file extension from original name
const extension = originalName.split('.').pop()?.toLowerCase() || 'jpg';
// Ensure filename is not empty
if (!filename) {
filename = 'image';
}
return `${filename}.${extension}`;
} catch (error) {
this.logger.error('Failed to generate SEO filename', error.stack);
return originalName; // Fallback to original name
}
}
}

Some files were not shown because too many files have changed in this diff Show more