diff --git a/.env.example b/.env.example deleted file mode 100644 index 06e688b..0000000 --- a/.env.example +++ /dev/null @@ -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 " -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 \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 7208a7f..0000000 --- a/.eslintrc.js +++ /dev/null @@ -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' - ] -}; \ No newline at end of file diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml deleted file mode 100644 index 2fc5fa2..0000000 --- a/.forgejo/workflows/ci.yml +++ /dev/null @@ -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" \ No newline at end of file diff --git a/.gitignore b/.gitignore deleted file mode 100644 index f4385d8..0000000 --- a/.gitignore +++ /dev/null @@ -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 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 1ce5765..0000000 --- a/.prettierrc +++ /dev/null @@ -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 - } - } - ] -} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 72b373a..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9942971..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/cypress.config.js b/cypress.config.js deleted file mode 100644 index 4992efa..0000000 --- a/cypress.config.js +++ /dev/null @@ -1,82 +0,0 @@ -const { defineConfig } = require('cypress'); - -module.exports = 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, - }, -}); \ No newline at end of file diff --git a/cypress/e2e/auth.cy.ts b/cypress/e2e/auth.cy.ts deleted file mode 100644 index e616d1c..0000000 --- a/cypress/e2e/auth.cy.ts +++ /dev/null @@ -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); - }); - }); -}); \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index 8c3c8b0..0000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -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 - 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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 67b9ee1..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 1bcb37a..0000000 --- a/jest.config.js +++ /dev/null @@ -1,41 +0,0 @@ -module.exports = { - displayName: 'SEO Image Renamer API', - testEnvironment: 'node', - rootDir: 'packages/api', - testMatch: [ - '/src/**/*.spec.ts', - '/src/**/*.test.ts', - '/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: ['/test/setup.ts'], - moduleNameMapping: { - '^@/(.*)$': '/src/$1', - }, - testTimeout: 30000, - maxWorkers: 4, - verbose: true, - detectOpenHandles: true, - forceExit: true, -}; \ No newline at end of file diff --git a/k8s/api-deployment.yaml b/k8s/api-deployment.yaml deleted file mode 100644 index 8625b2b..0000000 --- a/k8s/api-deployment.yaml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml deleted file mode 100644 index 12c03aa..0000000 --- a/k8s/configmap.yaml +++ /dev/null @@ -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" \ No newline at end of file diff --git a/k8s/frontend-deployment.yaml b/k8s/frontend-deployment.yaml deleted file mode 100644 index 452a9eb..0000000 --- a/k8s/frontend-deployment.yaml +++ /dev/null @@ -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; - } - } - } \ No newline at end of file diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml deleted file mode 100644 index d50a8b9..0000000 --- a/k8s/namespace.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: seo-image-renamer - labels: - app: seo-image-renamer - environment: production \ No newline at end of file diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml deleted file mode 100644 index ee49583..0000000 --- a/k8s/secrets.yaml +++ /dev/null @@ -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= \ No newline at end of file diff --git a/k8s/worker-deployment.yaml b/k8s/worker-deployment.yaml deleted file mode 100644 index cb708f3..0000000 --- a/k8s/worker-deployment.yaml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/logs/.d56b634021edaec5c4c8bdac5c3c6d66231098da-audit.json b/logs/.d56b634021edaec5c4c8bdac5c3c6d66231098da-audit.json deleted file mode 100644 index a540441..0000000 --- a/logs/.d56b634021edaec5c4c8bdac5c3c6d66231098da-audit.json +++ /dev/null @@ -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" -} \ No newline at end of file diff --git a/logs/mcp-puppeteer-2025-08-05.log b/logs/mcp-puppeteer-2025-08-05.log deleted file mode 100644 index 6ce4e38..0000000 --- a/logs/mcp-puppeteer-2025-08-05.log +++ /dev/null @@ -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"} diff --git a/package.json b/package.json deleted file mode 100644 index b47d3e6..0000000 --- a/package.json +++ /dev/null @@ -1,78 +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" - }, - "workspaces": [ - "packages/api", - "packages/worker", - "packages/frontend" - ], - "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" -} \ No newline at end of file diff --git a/packages/api/.env.example b/packages/api/.env.example deleted file mode 100644 index 55843d4..0000000 --- a/packages/api/.env.example +++ /dev/null @@ -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" \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json deleted file mode 100644 index d36f746..0000000 --- a/packages/api/package.json +++ /dev/null @@ -1,113 +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/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/config": "^3.1.1", - "@nestjs/jwt": "^10.2.0", - "@nestjs/passport": "^10.0.2", - "@nestjs/swagger": "^7.1.17", - "@nestjs/websockets": "^10.0.0", - "@nestjs/platform-socket.io": "^10.0.0", - "@nestjs/bullmq": "^10.0.1", - "@prisma/client": "^5.7.0", - "prisma": "^5.7.0", - "passport": "^0.7.0", - "passport-jwt": "^4.0.1", - "passport-google-oauth20": "^2.0.0", - "class-validator": "^0.14.0", - "class-transformer": "^0.5.1", - "bcrypt": "^5.1.1", - "helmet": "^7.1.0", - "compression": "^1.7.4", - "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1", - "uuid": "^9.0.1", - "stripe": "^14.10.0", - "cookie-parser": "^1.4.6", - "socket.io": "^4.7.4", - "bullmq": "^4.15.2", - "ioredis": "^5.3.2", - "minio": "^7.1.3", - "multer": "^1.4.5-lts.1", - "sharp": "^0.33.0", - "crypto": "^1.0.1", - "openai": "^4.24.1", - "axios": "^1.6.2" - }, - "devDependencies": { - "@nestjs/cli": "^10.0.0", - "@nestjs/schematics": "^10.0.0", - "@nestjs/testing": "^10.0.0", - "@types/express": "^4.17.17", - "@types/jest": "^29.5.2", - "@types/node": "^20.3.1", - "@types/supertest": "^2.0.12", - "@types/passport-jwt": "^3.0.13", - "@types/passport-google-oauth20": "^2.0.14", - "@types/bcrypt": "^5.0.2", - "@types/uuid": "^9.0.7", - "@types/cookie-parser": "^1.4.6", - "@types/multer": "^1.4.11", - "@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.1", - "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" - } -} \ No newline at end of file diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma deleted file mode 100644 index 722d172..0000000 --- a/packages/api/prisma/schema.prisma +++ /dev/null @@ -1,179 +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 - DONE - ERROR -} - -// 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") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - // Relations - batches Batch[] - payments Payment[] - apiKeys ApiKey[] - - @@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") - 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[] - - @@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 - 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]) -} \ No newline at end of file diff --git a/packages/api/prisma/seed.ts b/packages/api/prisma/seed.ts deleted file mode 100644 index 0b359c7..0000000 --- a/packages/api/prisma/seed.ts +++ /dev/null @@ -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.DONE, - 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.ERROR, - 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(); - }); \ No newline at end of file diff --git a/packages/api/src/admin/admin.controller.ts b/packages/api/src/admin/admin.controller.ts deleted file mode 100644 index 67f142e..0000000 --- a/packages/api/src/admin/admin.controller.ts +++ /dev/null @@ -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, - ); - 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, - 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) { - 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, - ); - } - } -} \ No newline at end of file diff --git a/packages/api/src/admin/admin.module.ts b/packages/api/src/admin/admin.module.ts deleted file mode 100644 index 06c607b..0000000 --- a/packages/api/src/admin/admin.module.ts +++ /dev/null @@ -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 {} \ No newline at end of file diff --git a/packages/api/src/app.module.ts b/packages/api/src/app.module.ts deleted file mode 100644 index 2b10bfb..0000000 --- a/packages/api/src/app.module.ts +++ /dev/null @@ -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/*'); - } -} \ No newline at end of file diff --git a/packages/api/src/auth/auth.controller.ts b/packages/api/src/auth/auth.controller.ts deleted file mode 100644 index ae689a3..0000000 --- a/packages/api/src/auth/auth.controller.ts +++ /dev/null @@ -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 { - 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 { - 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, - }, - }; - } -} \ No newline at end of file diff --git a/packages/api/src/auth/auth.guard.ts b/packages/api/src/auth/auth.guard.ts deleted file mode 100644 index 63cb7e8..0000000 --- a/packages/api/src/auth/auth.guard.ts +++ /dev/null @@ -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 | Observable { - // Check if route is marked as public - const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ - context.getHandler(), - context.getClass(), - ]); - - if (isPublic) { - return true; - } - - // Check if route has optional authentication - const isOptionalAuth = this.reflector.getAllAndOverride( - 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( - 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', - }); - } -} \ No newline at end of file diff --git a/packages/api/src/auth/auth.module.ts b/packages/api/src/auth/auth.module.ts deleted file mode 100644 index b08fa29..0000000 --- a/packages/api/src/auth/auth.module.ts +++ /dev/null @@ -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('JWT_SECRET'), - signOptions: { - expiresIn: configService.get('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 {} \ No newline at end of file diff --git a/packages/api/src/auth/auth.service.spec.ts b/packages/api/src/auth/auth.service.spec.ts deleted file mode 100644 index 15c43c1..0000000 --- a/packages/api/src/auth/auth.service.spec.ts +++ /dev/null @@ -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; - let jwtService: jest.Mocked; - let configService: jest.Mocked; - - 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); - 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); - }); - }); -}); \ No newline at end of file diff --git a/packages/api/src/auth/auth.service.ts b/packages/api/src/auth/auth.service.ts deleted file mode 100644 index eb84434..0000000 --- a/packages/api/src/auth/auth.service.ts +++ /dev/null @@ -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 { - 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 { - 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 { - return await this.userRepository.findById(userId); - } - - /** - * Generate JWT token for user - */ - async generateTokens(user: User): Promise { - 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 { - 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('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 { - 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' }; - } -} \ No newline at end of file diff --git a/packages/api/src/auth/dto/auth.dto.ts b/packages/api/src/auth/dto/auth.dto.ts deleted file mode 100644 index 80f60eb..0000000 --- a/packages/api/src/auth/dto/auth.dto.ts +++ /dev/null @@ -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 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 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 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; -} \ No newline at end of file diff --git a/packages/api/src/auth/google.strategy.ts b/packages/api/src/auth/google.strategy.ts deleted file mode 100644 index 2a14a5e..0000000 --- a/packages/api/src/auth/google.strategy.ts +++ /dev/null @@ -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('GOOGLE_CLIENT_ID'), - clientSecret: configService.get('GOOGLE_CLIENT_SECRET'), - callbackURL: configService.get('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 { - 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); - } - } -} \ No newline at end of file diff --git a/packages/api/src/auth/jwt.strategy.ts b/packages/api/src/auth/jwt.strategy.ts deleted file mode 100644 index cd428b5..0000000 --- a/packages/api/src/auth/jwt.strategy.ts +++ /dev/null @@ -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('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'); - } - } -} \ No newline at end of file diff --git a/packages/api/src/batches/batch.entity.ts b/packages/api/src/batches/batch.entity.ts deleted file mode 100644 index 13dcf52..0000000 --- a/packages/api/src/batches/batch.entity.ts +++ /dev/null @@ -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; -} - -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; -} - -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; - - @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.DONE || - batch.status === BatchStatus.ERROR || - (batch.processedImages + batch.failedImages) >= batch.totalImages; -} \ No newline at end of file diff --git a/packages/api/src/batches/batches.controller.ts b/packages/api/src/batches/batches.controller.ts deleted file mode 100644 index 30b9a69..0000000 --- a/packages/api/src/batches/batches.controller.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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'); - } - } -} \ No newline at end of file diff --git a/packages/api/src/batches/batches.module.ts b/packages/api/src/batches/batches.module.ts deleted file mode 100644 index 1ab6023..0000000 --- a/packages/api/src/batches/batches.module.ts +++ /dev/null @@ -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 {} \ No newline at end of file diff --git a/packages/api/src/batches/batches.service.ts b/packages/api/src/batches/batches.service.ts deleted file mode 100644 index 573594b..0000000 --- a/packages/api/src/batches/batches.service.ts +++ /dev/null @@ -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 { - 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 { - 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.DONE: - state = 'DONE'; - break; - case BatchStatus.ERROR: - 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.ERROR ? '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 { - 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.DONE ? '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 { - 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.ERROR, - completedAt: new Date(), - metadata: { - ...batch.metadata, - 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 { - 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.DONE, - }, - 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 { - 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.ERROR : BatchStatus.DONE) : - 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.DONE ? '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); - } - } -} \ No newline at end of file diff --git a/packages/api/src/batches/dto/batch-status.dto.ts b/packages/api/src/batches/dto/batch-status.dto.ts deleted file mode 100644 index e46d252..0000000 --- a/packages/api/src/batches/dto/batch-status.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/packages/api/src/batches/dto/create-batch.dto.ts b/packages/api/src/batches/dto/create-batch.dto.ts deleted file mode 100644 index 519fb3b..0000000 --- a/packages/api/src/batches/dto/create-batch.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/packages/api/src/common/middleware/rate-limit.middleware.ts b/packages/api/src/common/middleware/rate-limit.middleware.ts deleted file mode 100644 index 9035c05..0000000 --- a/packages/api/src/common/middleware/rate-limit.middleware.ts +++ /dev/null @@ -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]; - } - } - } -} \ No newline at end of file diff --git a/packages/api/src/common/middleware/security.middleware.ts b/packages/api/src/common/middleware/security.middleware.ts deleted file mode 100644 index 4327021..0000000 --- a/packages/api/src/common/middleware/security.middleware.ts +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/packages/api/src/database/database.module.ts b/packages/api/src/database/database.module.ts deleted file mode 100644 index fe3cea6..0000000 --- a/packages/api/src/database/database.module.ts +++ /dev/null @@ -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 {} \ No newline at end of file diff --git a/packages/api/src/database/prisma.service.ts b/packages/api/src/database/prisma.service.ts deleted file mode 100644 index 94a5e24..0000000 --- a/packages/api/src/database/prisma.service.ts +++ /dev/null @@ -1,138 +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('DATABASE_URL'), - }, - }, - log: [ - { - emit: 'event', - level: 'query', - }, - { - emit: 'event', - level: 'error', - }, - { - emit: 'event', - level: 'info', - }, - { - emit: 'event', - level: 'warn', - }, - ], - errorFormat: 'colorless', - }); - - // Log database queries in development - if (configService.get('NODE_ENV') === 'development') { - this.$on('query', (e) => { - this.logger.debug(`Query: ${e.query}`); - this.logger.debug(`Params: ${e.params}`); - this.logger.debug(`Duration: ${e.duration}ms`); - }); - } - - // Log database errors - this.$on('error', (e) => { - this.logger.error('Database error:', e); - }); - - // Log database info - this.$on('info', (e) => { - this.logger.log(`Database info: ${e.message}`); - }); - - // Log database warnings - this.$on('warn', (e) => { - this.logger.warn(`Database warning: ${e.message}`); - }); - } - - 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 { - 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(fn: (prisma: PrismaClient) => Promise): Promise { - return this.$transaction(fn); - } -} \ No newline at end of file diff --git a/packages/api/src/database/repositories/batch.repository.ts b/packages/api/src/database/repositories/batch.repository.ts deleted file mode 100644 index 95ba4a2..0000000 --- a/packages/api/src/database/repositories/batch.repository.ts +++ /dev/null @@ -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 { - 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 { - 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 { - try { - const updateData: any = { ...data }; - - // Set completedAt if status is changing to DONE or ERROR - if (data.status && (data.status === BatchStatus.DONE || data.status === BatchStatus.ERROR)) { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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.ERROR : BatchStatus.DONE; - 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 { - 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 { - 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 { - 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.DONE }), - this.count({ userId, status: BatchStatus.PROCESSING }), - this.count({ userId, status: BatchStatus.ERROR }), - 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; - } - } -} \ No newline at end of file diff --git a/packages/api/src/database/repositories/image.repository.ts b/packages/api/src/database/repositories/image.repository.ts deleted file mode 100644 index 88814a9..0000000 --- a/packages/api/src/database/repositories/image.repository.ts +++ /dev/null @@ -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 { - try { - return await this.prisma.image.create({ - data: { - ...data, - status: ImageStatus.PENDING, - }, - }); - } 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, - skipDuplicates: true, - }); - } catch (error) { - this.logger.error('Failed to create multiple images:', error); - throw error; - } - } - - /** - * Find image by ID - */ - async findById(id: string): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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; - } - } -} \ No newline at end of file diff --git a/packages/api/src/database/repositories/payment.repository.ts b/packages/api/src/database/repositories/payment.repository.ts deleted file mode 100644 index 9568025..0000000 --- a/packages/api/src/database/repositories/payment.repository.ts +++ /dev/null @@ -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 { - try { - return await this.prisma.payment.create({ - data: { - ...data, - status: PaymentStatus.PENDING, - }, - }); - } catch (error) { - this.logger.error('Failed to create payment:', error); - throw error; - } - } - - /** - * Find payment by ID - */ - async findById(id: string): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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; - paymentsCount: Record; - }> { - 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); - - 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); - - 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 { - 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; - } - } -} \ No newline at end of file diff --git a/packages/api/src/database/repositories/user.repository.ts b/packages/api/src/database/repositories/user.repository.ts deleted file mode 100644 index 74a18af..0000000 --- a/packages/api/src/database/repositories/user.repository.ts +++ /dev/null @@ -1,376 +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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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); - } -} \ No newline at end of file diff --git a/packages/api/src/download/download.controller.ts b/packages/api/src/download/download.controller.ts deleted file mode 100644 index e431624..0000000 --- a/packages/api/src/download/download.controller.ts +++ /dev/null @@ -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, - ); - } - } -} \ No newline at end of file diff --git a/packages/api/src/download/download.module.ts b/packages/api/src/download/download.module.ts deleted file mode 100644 index 999dc8a..0000000 --- a/packages/api/src/download/download.module.ts +++ /dev/null @@ -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 {} \ No newline at end of file diff --git a/packages/api/src/download/download.service.ts b/packages/api/src/download/download.service.ts deleted file mode 100644 index 30770d5..0000000 --- a/packages/api/src/download/download.service.ts +++ /dev/null @@ -1,516 +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 { - 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, - status: 'READY', - totalSize, - fileCount: images.length, - expiresAt, - downloadUrl: this.generateDownloadUrl(downloadId), - }, - }); - - 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 }, - include: { - batch: { - select: { - id: true, - name: true, - status: true, - }, - }, - }, - }); - - 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.batch?.name, - totalSize: download.totalSize, - fileCount: download.fileCount, - downloadUrl: download.downloadUrl, - expiresAt: download.expiresAt, - downloadCount: download.downloadCount, - 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 { - try { - const download = await this.prisma.download.findUnique({ - where: { id: downloadId }, - include: { - batch: true, - }, - }); - - 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.originalFilename, - 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 = `${download.batch?.name || '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 { - try { - await this.prisma.download.update({ - where: { id: downloadId }, - data: { - downloadCount: { - increment: 1, - }, - lastDownloadedAt: 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 }, - include: { - batch: { - select: { - id: true, - name: true, - status: true, - }, - }, - }, - orderBy: { - createdAt: 'desc', - }, - take: limit, - }); - - return downloads.map(download => ({ - id: download.id, - batchId: download.batchId, - batchName: download.batch?.name, - status: download.status, - totalSize: download.totalSize, - fileCount: download.fileCount, - downloadCount: download.downloadCount, - createdAt: download.createdAt, - expiresAt: download.expiresAt, - lastDownloadedAt: download.lastDownloadedAt, - 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 { - 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.originalFilename, - newName: image.generatedFilename || image.originalFilename, - size: fileSize, - status: image.status, - hasChanges: image.generatedFilename !== image.originalFilename, - }); - } - - 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 { - 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('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; - } - } -} \ No newline at end of file diff --git a/packages/api/src/download/dto/create-download.dto.ts b/packages/api/src/download/dto/create-download.dto.ts deleted file mode 100644 index 425dd98..0000000 --- a/packages/api/src/download/dto/create-download.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/packages/api/src/download/services/exif.service.ts b/packages/api/src/download/services/exif.service.ts deleted file mode 100644 index b702e84..0000000 --- a/packages/api/src/download/services/exif.service.ts +++ /dev/null @@ -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 { - 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 { - try { - const 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) { - jpegOptions.withMetadata = true; - } - - 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 { - 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 { - 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 { - 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 - } -} \ No newline at end of file diff --git a/packages/api/src/download/services/zip.service.ts b/packages/api/src/download/services/zip.service.ts deleted file mode 100644 index 8604579..0000000 --- a/packages/api/src/download/services/zip.service.ts +++ /dev/null @@ -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 { - 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 { - 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, - file.originalPath, - ); - - archive.append(processedStream, { - name: this.sanitizeFilename(file.name), - }); - } else { - // Add file as-is - archive.append(fileStream, { - 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 { - 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 { - 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(); - const duplicates = new Set(); - - 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; - } -} \ No newline at end of file diff --git a/packages/api/src/images/dto/image-response.dto.ts b/packages/api/src/images/dto/image-response.dto.ts deleted file mode 100644 index 801a704..0000000 --- a/packages/api/src/images/dto/image-response.dto.ts +++ /dev/null @@ -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; - }; -} \ No newline at end of file diff --git a/packages/api/src/images/dto/update-filename.dto.ts b/packages/api/src/images/dto/update-filename.dto.ts deleted file mode 100644 index a23ce17..0000000 --- a/packages/api/src/images/dto/update-filename.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/packages/api/src/images/image.entity.ts b/packages/api/src/images/image.entity.ts deleted file mode 100644 index 6b06704..0000000 --- a/packages/api/src/images/image.entity.ts +++ /dev/null @@ -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}`; -} \ No newline at end of file diff --git a/packages/api/src/images/images.controller.ts b/packages/api/src/images/images.controller.ts deleted file mode 100644 index ee65fe4..0000000 --- a/packages/api/src/images/images.controller.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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'); - } - } -} \ No newline at end of file diff --git a/packages/api/src/images/images.module.ts b/packages/api/src/images/images.module.ts deleted file mode 100644 index 3b98019..0000000 --- a/packages/api/src/images/images.module.ts +++ /dev/null @@ -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 {} \ No newline at end of file diff --git a/packages/api/src/images/images.service.ts b/packages/api/src/images/images.service.ts deleted file mode 100644 index db2848b..0000000 --- a/packages/api/src/images/images.service.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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); - } -} \ No newline at end of file diff --git a/packages/api/src/keywords/dto/enhance-keywords.dto.ts b/packages/api/src/keywords/dto/enhance-keywords.dto.ts deleted file mode 100644 index d74d92f..0000000 --- a/packages/api/src/keywords/dto/enhance-keywords.dto.ts +++ /dev/null @@ -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; - }; -} \ No newline at end of file diff --git a/packages/api/src/keywords/keywords.controller.ts b/packages/api/src/keywords/keywords.controller.ts deleted file mode 100644 index a63d168..0000000 --- a/packages/api/src/keywords/keywords.controller.ts +++ /dev/null @@ -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 { - 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; - }> { - 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'); - } - } -} \ No newline at end of file diff --git a/packages/api/src/keywords/keywords.module.ts b/packages/api/src/keywords/keywords.module.ts deleted file mode 100644 index 3ad3af5..0000000 --- a/packages/api/src/keywords/keywords.module.ts +++ /dev/null @@ -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 {} \ No newline at end of file diff --git a/packages/api/src/keywords/keywords.service.ts b/packages/api/src/keywords/keywords.service.ts deleted file mode 100644 index 3c7a237..0000000 --- a/packages/api/src/keywords/keywords.service.ts +++ /dev/null @@ -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(); - 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('OPENAI_API_KEY'), - // }); - } - - /** - * Enhance keywords with AI suggestions - */ - async enhanceKeywords( - keywords: string[], - userId: string, - ): Promise { - 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; - }> { - 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 { - 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 { - // 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 { - // Simulate AI processing time - await new Promise(resolve => setTimeout(resolve, 300)); - - // Mock related keywords - replace with actual AI generation - const relatedMap: Record = { - 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 { - // 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: '' }; - } -} \ No newline at end of file diff --git a/packages/api/src/main.ts b/packages/api/src/main.ts deleted file mode 100644 index d02699a..0000000 --- a/packages/api/src/main.ts +++ /dev/null @@ -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('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('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('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); -}); \ No newline at end of file diff --git a/packages/api/src/monitoring/monitoring.module.ts b/packages/api/src/monitoring/monitoring.module.ts deleted file mode 100644 index 4b38bca..0000000 --- a/packages/api/src/monitoring/monitoring.module.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { PrometheusModule } from '@willsoto/nestjs-prometheus'; -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'; - -@Module({ - imports: [ - ConfigModule, - PrometheusModule.register({ - path: '/metrics', - defaultMetrics: { - enabled: true, - config: { - prefix: 'seo_image_renamer_', - }, - }, - }), - ], - controllers: [ - HealthController, - MetricsController, - ], - providers: [ - MonitoringService, - MetricsService, - TracingService, - HealthService, - LoggingService, - ], - exports: [ - MonitoringService, - MetricsService, - TracingService, - HealthService, - LoggingService, - ], -}) -export class MonitoringModule {} \ No newline at end of file diff --git a/packages/api/src/monitoring/services/metrics.service.ts b/packages/api/src/monitoring/services/metrics.service.ts deleted file mode 100644 index 6d47e8e..0000000 --- a/packages/api/src/monitoring/services/metrics.service.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { - makeCounterProvider, - makeHistogramProvider, - makeGaugeProvider, -} from '@willsoto/nestjs-prometheus'; -import { Counter, Histogram, Gauge, register } from 'prom-client'; - -@Injectable() -export class MetricsService { - private readonly logger = new Logger(MetricsService.name); - - // Request metrics - private readonly httpRequestsTotal: Counter; - private readonly httpRequestDuration: Histogram; - - // Business metrics - private readonly imagesProcessedTotal: Counter; - private readonly batchesCreatedTotal: Counter; - private readonly downloadsTotal: Counter; - private readonly paymentsTotal: Counter; - private readonly usersRegisteredTotal: Counter; - - // System metrics - private readonly activeConnections: Gauge; - private readonly queueSize: Gauge; - private readonly processingTime: Histogram; - private readonly errorRate: Counter; - - // Resource metrics - private readonly memoryUsage: Gauge; - private readonly cpuUsage: Gauge; - private readonly diskUsage: Gauge; - - constructor() { - // HTTP Request metrics - this.httpRequestsTotal = new Counter({ - name: 'seo_http_requests_total', - help: 'Total number of HTTP requests', - labelNames: ['method', 'route', 'status_code'], - }); - - this.httpRequestDuration = new Histogram({ - name: 'seo_http_request_duration_seconds', - help: 'Duration of HTTP requests in seconds', - labelNames: ['method', 'route', 'status_code'], - buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10], - }); - - // Business metrics - this.imagesProcessedTotal = new Counter({ - name: 'seo_images_processed_total', - help: 'Total number of images processed', - labelNames: ['status', 'user_plan'], - }); - - this.batchesCreatedTotal = new Counter({ - name: 'seo_batches_created_total', - help: 'Total number of batches created', - labelNames: ['user_plan'], - }); - - this.downloadsTotal = new Counter({ - name: 'seo_downloads_total', - help: 'Total number of downloads', - labelNames: ['user_plan'], - }); - - this.paymentsTotal = new Counter({ - name: 'seo_payments_total', - help: 'Total number of payments', - labelNames: ['status', 'plan'], - }); - - this.usersRegisteredTotal = new Counter({ - name: 'seo_users_registered_total', - help: 'Total number of users registered', - labelNames: ['auth_provider'], - }); - - // System metrics - this.activeConnections = new Gauge({ - name: 'seo_active_connections', - help: 'Number of active WebSocket connections', - }); - - this.queueSize = new Gauge({ - name: 'seo_queue_size', - help: 'Number of jobs in queue', - labelNames: ['queue_name'], - }); - - this.processingTime = new Histogram({ - name: 'seo_processing_time_seconds', - help: 'Time taken to process images', - labelNames: ['operation'], - buckets: [1, 5, 10, 30, 60, 120, 300], - }); - - this.errorRate = new Counter({ - name: 'seo_errors_total', - help: 'Total number of errors', - labelNames: ['type', 'service'], - }); - - // Resource metrics - this.memoryUsage = new Gauge({ - name: 'seo_memory_usage_bytes', - help: 'Memory usage in bytes', - }); - - this.cpuUsage = new Gauge({ - name: 'seo_cpu_usage_percent', - help: 'CPU usage percentage', - }); - - this.diskUsage = new Gauge({ - name: 'seo_disk_usage_bytes', - help: 'Disk usage in bytes', - labelNames: ['mount_point'], - }); - - // Register all metrics - register.registerMetric(this.httpRequestsTotal); - register.registerMetric(this.httpRequestDuration); - register.registerMetric(this.imagesProcessedTotal); - register.registerMetric(this.batchesCreatedTotal); - register.registerMetric(this.downloadsTotal); - register.registerMetric(this.paymentsTotal); - register.registerMetric(this.usersRegisteredTotal); - register.registerMetric(this.activeConnections); - register.registerMetric(this.queueSize); - register.registerMetric(this.processingTime); - register.registerMetric(this.errorRate); - register.registerMetric(this.memoryUsage); - register.registerMetric(this.cpuUsage); - register.registerMetric(this.diskUsage); - - this.logger.log('Metrics service initialized'); - } - - // HTTP Request metrics - recordHttpRequest(method: string, route: string, statusCode: number, duration: number) { - this.httpRequestsTotal.inc({ - method, - route, - status_code: statusCode.toString() - }); - - this.httpRequestDuration.observe( - { method, route, status_code: statusCode.toString() }, - duration / 1000 // Convert ms to seconds - ); - } - - // Business metrics - recordImageProcessed(status: 'success' | 'failed', userPlan: string) { - this.imagesProcessedTotal.inc({ status, user_plan: userPlan }); - } - - recordBatchCreated(userPlan: string) { - this.batchesCreatedTotal.inc({ user_plan: userPlan }); - } - - recordDownload(userPlan: string) { - this.downloadsTotal.inc({ user_plan: userPlan }); - } - - recordPayment(status: string, plan: string) { - this.paymentsTotal.inc({ status, plan }); - } - - recordUserRegistration(authProvider: string) { - this.usersRegisteredTotal.inc({ auth_provider: authProvider }); - } - - // System metrics - setActiveConnections(count: number) { - this.activeConnections.set(count); - } - - setQueueSize(queueName: string, size: number) { - this.queueSize.set({ queue_name: queueName }, size); - } - - recordProcessingTime(operation: string, timeSeconds: number) { - this.processingTime.observe({ operation }, timeSeconds); - } - - recordError(type: string, service: string) { - this.errorRate.inc({ type, service }); - } - - // Resource metrics - updateSystemMetrics() { - try { - const memUsage = process.memoryUsage(); - this.memoryUsage.set(memUsage.heapUsed); - - // CPU usage would require additional libraries like 'pidusage' - // For now, we'll skip it or use process.cpuUsage() - - } catch (error) { - this.logger.error('Failed to update system metrics:', error); - } - } - - // Custom metrics - createCustomCounter(name: string, help: string, labelNames: string[] = []) { - const counter = new Counter({ - name: `seo_${name}`, - help, - labelNames, - }); - - register.registerMetric(counter); - return counter; - } - - createCustomGauge(name: string, help: string, labelNames: string[] = []) { - const gauge = new Gauge({ - name: `seo_${name}`, - help, - labelNames, - }); - - register.registerMetric(gauge); - return gauge; - } - - createCustomHistogram( - name: string, - help: string, - buckets: number[] = [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10], - labelNames: string[] = [] - ) { - const histogram = new Histogram({ - name: `seo_${name}`, - help, - buckets, - labelNames, - }); - - register.registerMetric(histogram); - return histogram; - } - - // Get all metrics - async getMetrics(): Promise { - return register.metrics(); - } - - // Reset all metrics (for testing) - resetMetrics() { - register.resetMetrics(); - } - - // Health check for metrics service - isHealthy(): boolean { - try { - // Basic health check - ensure we can collect metrics - register.metrics(); - return true; - } catch (error) { - this.logger.error('Metrics service health check failed:', error); - return false; - } - } - - // Get metric summary for monitoring - getMetricsSummary() { - return { - httpRequests: this.httpRequestsTotal, - imagesProcessed: this.imagesProcessedTotal, - batchesCreated: this.batchesCreatedTotal, - downloads: this.downloadsTotal, - payments: this.paymentsTotal, - errors: this.errorRate, - activeConnections: this.activeConnections, - }; - } -} \ No newline at end of file diff --git a/packages/api/src/payments/dto/create-checkout-session.dto.ts b/packages/api/src/payments/dto/create-checkout-session.dto.ts deleted file mode 100644 index 1ac45ba..0000000 --- a/packages/api/src/payments/dto/create-checkout-session.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/packages/api/src/payments/dto/create-portal-session.dto.ts b/packages/api/src/payments/dto/create-portal-session.dto.ts deleted file mode 100644 index d46d969..0000000 --- a/packages/api/src/payments/dto/create-portal-session.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/packages/api/src/payments/payment.entity.ts b/packages/api/src/payments/payment.entity.ts deleted file mode 100644 index 1b9a9c8..0000000 --- a/packages/api/src/payments/payment.entity.ts +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/packages/api/src/payments/payments.controller.ts b/packages/api/src/payments/payments.controller.ts deleted file mode 100644 index f2c79f0..0000000 --- a/packages/api/src/payments/payments.controller.ts +++ /dev/null @@ -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, - @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, - ); - } - } -} \ No newline at end of file diff --git a/packages/api/src/payments/payments.module.ts b/packages/api/src/payments/payments.module.ts deleted file mode 100644 index 1a8a456..0000000 --- a/packages/api/src/payments/payments.module.ts +++ /dev/null @@ -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 {} \ No newline at end of file diff --git a/packages/api/src/payments/payments.service.spec.ts b/packages/api/src/payments/payments.service.spec.ts deleted file mode 100644 index 2e86e63..0000000 --- a/packages/api/src/payments/payments.service.spec.ts +++ /dev/null @@ -1,292 +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; - let subscriptionService: jest.Mocked; - let paymentRepository: jest.Mocked; - let userRepository: jest.Mocked; - - 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); - 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: { - id: 'sub_stripe_123', - status: 'ACTIVE', - currentPeriodStart: mockSubscription.currentPeriodStart, - currentPeriodEnd: mockSubscription.currentPeriodEnd, - cancelAtPeriodEnd: false, - }, - 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 cancel active subscription', async () => { - subscriptionService.getActiveSubscription.mockResolvedValue(mockSubscription); - stripeService.cancelSubscription.mockResolvedValue({} as any); - subscriptionService.markAsCancelled.mockResolvedValue({} as any); - - await service.cancelSubscription('user-123'); - - expect(stripeService.cancelSubscription).toHaveBeenCalledWith('sub_stripe_123'); - expect(subscriptionService.markAsCancelled).toHaveBeenCalledWith('sub-123'); - }); - - it('should throw NotFoundException if no active subscription found', async () => { - subscriptionService.getActiveSubscription.mockResolvedValue(null); - - await expect(service.cancelSubscription('user-123')).rejects.toThrow( - NotFoundException - ); - }); - }); - - 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); - }); - }); - - 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); - }); - }); -}); \ No newline at end of file diff --git a/packages/api/src/payments/payments.service.ts b/packages/api/src/payments/payments.service.ts deleted file mode 100644 index 0224dbd..0000000 --- a/packages/api/src/payments/payments.service.ts +++ /dev/null @@ -1,390 +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, 5); // Last 5 payments - - return { - currentPlan: user.plan, - quotaRemaining: user.quotaRemaining, - quotaLimit: this.getQuotaLimit(user.plan), - quotaResetDate: user.quotaResetDate, - subscription: subscription ? { - id: subscription.stripeSubscriptionId, - status: subscription.status, - currentPeriodStart: subscription.currentPeriodStart, - currentPeriodEnd: subscription.currentPeriodEnd, - cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, - } : null, - recentPayments: paymentHistory.map(payment => ({ - id: payment.id, - amount: payment.amount, - currency: payment.currency, - status: payment.status, - createdAt: payment.createdAt, - plan: payment.planUpgrade, - })), - }; - } catch (error) { - this.logger.error(`Failed to get subscription for user ${userId}:`, error); - throw error; - } - } - - /** - * Cancel user subscription - */ - async cancelSubscription(userId: string): Promise { - try { - 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 cancelled for user ${userId}`); - } catch (error) { - this.logger.error(`Failed to cancel subscription for user ${userId}:`, error); - throw error; - } - } - - /** - * Reactivate cancelled subscription - */ - async reactivateSubscription(userId: string): Promise { - try { - 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 reactivated for user ${userId}`); - } 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, 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 { - 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'); - } - - // 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, Plan.BASIC); - } - - 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 { - 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, - stripePaymentIntentId, - stripeCustomerId, - amount, - currency, - status: 'succeeded', - planUpgrade: plan, - }); - - // Update user plan and quota - await this.userRepository.updatePlan(user.id, plan); - await this.userRepository.resetQuota(user.id, plan); - - 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 { - 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, - stripePaymentIntentId, - stripeCustomerId, - amount, - currency, - status: 'failed', - }); - - 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 { - 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); - - 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, plan); - - 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 { - try { - 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.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 { - try { - 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.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 = { - 'price_pro_monthly': Plan.PRO, - 'price_max_monthly': Plan.MAX, - }; - - return priceToplanMap[priceId] || Plan.BASIC; - } -} \ No newline at end of file diff --git a/packages/api/src/payments/services/stripe.service.ts b/packages/api/src/payments/services/stripe.service.ts deleted file mode 100644 index 92260f3..0000000 --- a/packages/api/src/payments/services/stripe.service.ts +++ /dev/null @@ -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('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 { - 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: 'always_invoice', - }; - } - - 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 { - 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 { - 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 { - 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 { - 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 { - 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('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 { - 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 { - 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 { - 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 { - 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.BASIC]: '', // No price for free plan - [Plan.PRO]: this.configService.get('STRIPE_PRO_PRICE_ID') || 'price_pro_monthly', - [Plan.MAX]: this.configService.get('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 { - 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; - } - } -} \ No newline at end of file diff --git a/packages/api/src/payments/services/subscription.service.ts b/packages/api/src/payments/services/subscription.service.ts deleted file mode 100644 index 22bdc0e..0000000 --- a/packages/api/src/payments/services/subscription.service.ts +++ /dev/null @@ -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; - } - } -} \ No newline at end of file diff --git a/packages/api/src/payments/services/webhook.service.ts b/packages/api/src/payments/services/webhook.service.ts deleted file mode 100644 index 6efe593..0000000 --- a/packages/api/src/payments/services/webhook.service.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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; - } - } -} \ No newline at end of file diff --git a/packages/api/src/queue/processors/batch-processing.processor.ts b/packages/api/src/queue/processors/batch-processing.processor.ts deleted file mode 100644 index 3393389..0000000 --- a/packages/api/src/queue/processors/batch-processing.processor.ts +++ /dev/null @@ -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): Promise { - 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 { - 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 { - // 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 { - 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 { - 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 { - 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 - } - } -} \ No newline at end of file diff --git a/packages/api/src/queue/processors/image-processing.processor.ts b/packages/api/src/queue/processors/image-processing.processor.ts deleted file mode 100644 index 9500841..0000000 --- a/packages/api/src/queue/processors/image-processing.processor.ts +++ /dev/null @@ -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): Promise { - 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 { - 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 { - // 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 { - 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 - } - } -} \ No newline at end of file diff --git a/packages/api/src/queue/queue.module.ts b/packages/api/src/queue/queue.module.ts deleted file mode 100644 index e897f27..0000000 --- a/packages/api/src/queue/queue.module.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Module } from '@nestjs/common'; -import { BullModule } from '@nestjs/bullmq'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { QueueService } from './queue.service'; -import { ImageProcessingProcessor } from './processors/image-processing.processor'; -import { BatchProcessingProcessor } from './processors/batch-processing.processor'; - -@Module({ - imports: [ - BullModule.forRootAsync({ - imports: [ConfigModule], - useFactory: async (configService: ConfigService) => ({ - connection: { - host: configService.get('REDIS_HOST', 'localhost'), - port: configService.get('REDIS_PORT', 6379), - password: configService.get('REDIS_PASSWORD'), - db: configService.get('REDIS_DB', 0), - }, - defaultJobOptions: { - removeOnComplete: 100, - removeOnFail: 50, - attempts: 3, - backoff: { - type: 'exponential', - delay: 2000, - }, - }, - }), - inject: [ConfigService], - }), - BullModule.registerQueue( - { - name: 'image-processing', - defaultJobOptions: { - attempts: 3, - backoff: { - type: 'exponential', - delay: 1000, - }, - }, - }, - { - name: 'batch-processing', - defaultJobOptions: { - attempts: 2, - backoff: { - type: 'fixed', - delay: 5000, - }, - }, - } - ), - ], - providers: [ - QueueService, - ImageProcessingProcessor, - BatchProcessingProcessor, - ], - exports: [QueueService], -}) -export class QueueModule {} \ No newline at end of file diff --git a/packages/api/src/queue/queue.service.ts b/packages/api/src/queue/queue.service.ts deleted file mode 100644 index a8cf973..0000000 --- a/packages/api/src/queue/queue.service.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectQueue } from '@nestjs/bullmq'; -import { Queue, Job } from 'bullmq'; - -export interface ImageProcessingJobData { - imageId: string; - batchId: string; - s3Key: string; - originalName: string; - userId: string; - keywords?: string[]; -} - -export interface BatchProcessingJobData { - batchId: string; - userId: string; - imageIds: string[]; - keywords?: string[]; -} - -export interface JobProgress { - percentage: number; - currentImage?: string; - processedCount: number; - totalCount: number; - status: string; -} - -@Injectable() -export class QueueService { - private readonly logger = new Logger(QueueService.name); - - constructor( - @InjectQueue('image-processing') private imageQueue: Queue, - @InjectQueue('batch-processing') private batchQueue: Queue, - ) {} - - /** - * Add image processing job to queue - * @param data Image processing job data - * @returns Job instance - */ - async addImageProcessingJob(data: ImageProcessingJobData): Promise { - try { - const job = await this.imageQueue.add('process-image', data, { - jobId: `image-${data.imageId}`, - priority: 1, - delay: 0, - }); - - this.logger.log(`Added image processing job: ${job.id} for image: ${data.imageId}`); - return job; - } catch (error) { - this.logger.error(`Failed to add image processing job: ${data.imageId}`, error.stack); - throw error; - } - } - - /** - * Add batch processing job to queue - * @param data Batch processing job data - * @returns Job instance - */ - async addBatchProcessingJob(data: BatchProcessingJobData): Promise { - try { - const job = await this.batchQueue.add('process-batch', data, { - jobId: `batch-${data.batchId}`, - priority: 2, - delay: 1000, // Small delay to ensure all images are uploaded first - }); - - this.logger.log(`Added batch processing job: ${job.id} for batch: ${data.batchId}`); - return job; - } catch (error) { - this.logger.error(`Failed to add batch processing job: ${data.batchId}`, error.stack); - throw error; - } - } - - /** - * Get job status and progress - * @param jobId Job ID - * @param queueName Queue name - * @returns Job status and progress - */ - async getJobStatus(jobId: string, queueName: 'image-processing' | 'batch-processing'): Promise<{ - status: string; - progress: JobProgress | null; - error?: string; - }> { - try { - const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; - const job = await queue.getJob(jobId); - - if (!job) { - return { status: 'not-found', progress: null }; - } - - const state = await job.getState(); - const progress = job.progress as JobProgress | null; - - return { - status: state, - progress, - error: job.failedReason, - }; - } catch (error) { - this.logger.error(`Failed to get job status: ${jobId}`, error.stack); - throw error; - } - } - - /** - * Cancel a job - * @param jobId Job ID - * @param queueName Queue name - */ - async cancelJob(jobId: string, queueName: 'image-processing' | 'batch-processing'): Promise { - try { - const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; - const job = await queue.getJob(jobId); - - if (job) { - await job.remove(); - this.logger.log(`Cancelled job: ${jobId}`); - } - } catch (error) { - this.logger.error(`Failed to cancel job: ${jobId}`, error.stack); - throw error; - } - } - - /** - * Get queue statistics - * @param queueName Queue name - * @returns Queue statistics - */ - async getQueueStats(queueName: 'image-processing' | 'batch-processing') { - try { - const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; - - const [waiting, active, completed, failed, delayed] = await Promise.all([ - queue.getWaiting(), - queue.getActive(), - queue.getCompleted(), - queue.getFailed(), - queue.getDelayed(), - ]); - - return { - waiting: waiting.length, - active: active.length, - completed: completed.length, - failed: failed.length, - delayed: delayed.length, - total: waiting.length + active.length + completed.length + failed.length + delayed.length, - }; - } catch (error) { - this.logger.error(`Failed to get queue stats: ${queueName}`, error.stack); - throw error; - } - } - - /** - * Clean completed jobs from queue - * @param queueName Queue name - * @param maxAge Maximum age in milliseconds - */ - async cleanQueue(queueName: 'image-processing' | 'batch-processing', maxAge: number = 24 * 60 * 60 * 1000): Promise { - try { - const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; - - await queue.clean(maxAge, 100, 'completed'); - await queue.clean(maxAge, 50, 'failed'); - - this.logger.log(`Cleaned queue: ${queueName}`); - } catch (error) { - this.logger.error(`Failed to clean queue: ${queueName}`, error.stack); - throw error; - } - } - - /** - * Pause queue processing - * @param queueName Queue name - */ - async pauseQueue(queueName: 'image-processing' | 'batch-processing'): Promise { - try { - const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; - await queue.pause(); - this.logger.log(`Paused queue: ${queueName}`); - } catch (error) { - this.logger.error(`Failed to pause queue: ${queueName}`, error.stack); - throw error; - } - } - - /** - * Resume queue processing - * @param queueName Queue name - */ - async resumeQueue(queueName: 'image-processing' | 'batch-processing'): Promise { - try { - const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; - await queue.resume(); - this.logger.log(`Resumed queue: ${queueName}`); - } catch (error) { - this.logger.error(`Failed to resume queue: ${queueName}`, error.stack); - throw error; - } - } - - /** - * Add multiple image processing jobs - * @param jobsData Array of image processing job data - * @returns Array of job instances - */ - async addMultipleImageJobs(jobsData: ImageProcessingJobData[]): Promise { - try { - const jobs = await this.imageQueue.addBulk( - jobsData.map((data, index) => ({ - name: 'process-image', - data, - opts: { - jobId: `image-${data.imageId}`, - priority: 1, - delay: index * 100, // Stagger jobs slightly - }, - })) - ); - - this.logger.log(`Added ${jobs.length} image processing jobs`); - return jobs; - } catch (error) { - this.logger.error('Failed to add multiple image jobs', error.stack); - throw error; - } - } - - /** - * Get active jobs for monitoring - * @param queueName Queue name - * @returns Array of active jobs - */ - async getActiveJobs(queueName: 'image-processing' | 'batch-processing') { - try { - const queue = queueName === 'image-processing' ? this.imageQueue : this.batchQueue; - const activeJobs = await queue.getActive(); - - return activeJobs.map(job => ({ - id: job.id, - name: job.name, - data: job.data, - progress: job.progress, - processedOn: job.processedOn, - opts: job.opts, - })); - } catch (error) { - this.logger.error(`Failed to get active jobs: ${queueName}`, error.stack); - throw error; - } - } -} \ No newline at end of file diff --git a/packages/api/src/storage/storage.module.ts b/packages/api/src/storage/storage.module.ts deleted file mode 100644 index 2b1b101..0000000 --- a/packages/api/src/storage/storage.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { StorageService } from './storage.service'; - -@Module({ - imports: [ConfigModule], - providers: [StorageService], - exports: [StorageService], -}) -export class StorageModule {} \ No newline at end of file diff --git a/packages/api/src/storage/storage.service.ts b/packages/api/src/storage/storage.service.ts deleted file mode 100644 index 9dd6262..0000000 --- a/packages/api/src/storage/storage.service.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as Minio from 'minio'; -import { v4 as uuidv4 } from 'uuid'; -import * as crypto from 'crypto'; - -export interface StorageFile { - buffer: Buffer; - originalName: string; - mimeType: string; - size: number; -} - -export interface UploadResult { - key: string; - etag: string; - size: number; - checksum: string; -} - -@Injectable() -export class StorageService { - private readonly logger = new Logger(StorageService.name); - private readonly minioClient: Minio.Client; - private readonly bucketName: string; - - constructor(private configService: ConfigService) { - // Initialize MinIO client - this.minioClient = new Minio.Client({ - endPoint: this.configService.get('MINIO_ENDPOINT', 'localhost'), - port: this.configService.get('MINIO_PORT', 9000), - useSSL: this.configService.get('MINIO_USE_SSL', false), - accessKey: this.configService.get('MINIO_ACCESS_KEY', 'minioadmin'), - secretKey: this.configService.get('MINIO_SECRET_KEY', 'minioadmin'), - }); - - this.bucketName = this.configService.get('MINIO_BUCKET_NAME', 'seo-image-renamer'); - this.initializeBucket(); - } - - /** - * Initialize the bucket if it doesn't exist - */ - private async initializeBucket(): Promise { - try { - const bucketExists = await this.minioClient.bucketExists(this.bucketName); - if (!bucketExists) { - await this.minioClient.makeBucket(this.bucketName, 'us-east-1'); - this.logger.log(`Created bucket: ${this.bucketName}`); - } - } catch (error) { - this.logger.error(`Failed to initialize bucket: ${error.message}`, error.stack); - } - } - - /** - * Upload a file to MinIO storage - * @param file File data to upload - * @param batchId Batch UUID for organizing files - * @returns Upload result with key and metadata - */ - async uploadFile(file: StorageFile, batchId: string): Promise { - try { - // Generate file checksum - const checksum = crypto.createHash('sha256').update(file.buffer).digest('hex'); - - // Generate unique filename with batch organization - const fileExtension = this.getFileExtension(file.originalName); - const fileName = `${uuidv4()}${fileExtension}`; - const objectKey = `batches/${batchId}/${fileName}`; - - // Upload metadata - const metaData = { - 'Content-Type': file.mimeType, - 'Original-Name': file.originalName, - 'Upload-Date': new Date().toISOString(), - 'Checksum-SHA256': checksum, - }; - - // Upload file to MinIO - const uploadInfo = await this.minioClient.putObject( - this.bucketName, - objectKey, - file.buffer, - file.size, - metaData - ); - - this.logger.log(`File uploaded successfully: ${objectKey}`); - - return { - key: objectKey, - etag: uploadInfo.etag, - size: file.size, - checksum, - }; - } catch (error) { - this.logger.error(`Failed to upload file: ${error.message}`, error.stack); - throw new Error(`File upload failed: ${error.message}`); - } - } - - /** - * Get a file from MinIO storage - * @param objectKey Object key to retrieve - * @returns File stream - */ - async getFile(objectKey: string): Promise { - try { - return await this.minioClient.getObject(this.bucketName, objectKey); - } catch (error) { - this.logger.error(`Failed to retrieve file: ${objectKey}`, error.stack); - throw new Error(`File retrieval failed: ${error.message}`); - } - } - - /** - * Get file metadata - * @param objectKey Object key to get metadata for - * @returns File metadata - */ - async getFileMetadata(objectKey: string): Promise { - try { - return await this.minioClient.statObject(this.bucketName, objectKey); - } catch (error) { - this.logger.error(`Failed to get file metadata: ${objectKey}`, error.stack); - throw new Error(`File metadata retrieval failed: ${error.message}`); - } - } - - /** - * Delete a file from MinIO storage - * @param objectKey Object key to delete - */ - async deleteFile(objectKey: string): Promise { - try { - await this.minioClient.removeObject(this.bucketName, objectKey); - this.logger.log(`File deleted successfully: ${objectKey}`); - } catch (error) { - this.logger.error(`Failed to delete file: ${objectKey}`, error.stack); - throw new Error(`File deletion failed: ${error.message}`); - } - } - - /** - * List files in a batch folder - * @param batchId Batch UUID - * @returns Array of object keys - */ - async listBatchFiles(batchId: string): Promise { - try { - const objects: string[] = []; - const objectStream = this.minioClient.listObjects( - this.bucketName, - `batches/${batchId}/`, - true - ); - - return new Promise((resolve, reject) => { - objectStream.on('data', (obj) => { - objects.push(obj.name); - }); - - objectStream.on('error', (err) => { - this.logger.error(`Failed to list batch files: ${batchId}`, err); - reject(new Error(`Failed to list batch files: ${err.message}`)); - }); - - objectStream.on('end', () => { - resolve(objects); - }); - }); - } catch (error) { - this.logger.error(`Failed to list batch files: ${batchId}`, error.stack); - throw new Error(`Batch file listing failed: ${error.message}`); - } - } - - /** - * Delete all files in a batch folder - * @param batchId Batch UUID - */ - async deleteBatchFiles(batchId: string): Promise { - try { - const objectKeys = await this.listBatchFiles(batchId); - - if (objectKeys.length > 0) { - await this.minioClient.removeObjects(this.bucketName, objectKeys); - this.logger.log(`Deleted ${objectKeys.length} files for batch: ${batchId}`); - } - } catch (error) { - this.logger.error(`Failed to delete batch files: ${batchId}`, error.stack); - throw new Error(`Batch file deletion failed: ${error.message}`); - } - } - - /** - * Generate a presigned URL for file download - * @param objectKey Object key - * @param expiry Expiry time in seconds (default: 1 hour) - * @returns Presigned URL - */ - async getPresignedUrl(objectKey: string, expiry: number = 3600): Promise { - try { - return await this.minioClient.presignedGetObject(this.bucketName, objectKey, expiry); - } catch (error) { - this.logger.error(`Failed to generate presigned URL: ${objectKey}`, error.stack); - throw new Error(`Presigned URL generation failed: ${error.message}`); - } - } - - /** - * Check if file exists in storage - * @param objectKey Object key to check - * @returns Whether file exists - */ - async fileExists(objectKey: string): Promise { - try { - await this.minioClient.statObject(this.bucketName, objectKey); - return true; - } catch (error) { - if (error.code === 'NotFound') { - return false; - } - throw error; - } - } - - /** - * Calculate SHA-256 checksum for duplicate detection - * @param buffer File buffer - * @returns SHA-256 checksum - */ - calculateChecksum(buffer: Buffer): string { - return crypto.createHash('sha256').update(buffer).digest('hex'); - } - - /** - * Get file extension from filename - * @param filename Original filename - * @returns File extension with dot - */ - private getFileExtension(filename: string): string { - const lastDotIndex = filename.lastIndexOf('.'); - return lastDotIndex !== -1 ? filename.substring(lastDotIndex) : ''; - } - - /** - * Validate file MIME type for image uploads - * @param mimeType MIME type to validate - * @returns Whether MIME type is valid - */ - isValidImageMimeType(mimeType: string): boolean { - const validMimeTypes = [ - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/gif', - 'image/webp', - ]; - return validMimeTypes.includes(mimeType.toLowerCase()); - } -} \ No newline at end of file diff --git a/packages/api/src/upload/upload.module.ts b/packages/api/src/upload/upload.module.ts deleted file mode 100644 index d96dc6c..0000000 --- a/packages/api/src/upload/upload.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { StorageModule } from '../storage/storage.module'; -import { UploadService } from './upload.service'; - -@Module({ - imports: [StorageModule], - providers: [UploadService], - exports: [UploadService], -}) -export class UploadModule {} \ No newline at end of file diff --git a/packages/api/src/upload/upload.service.ts b/packages/api/src/upload/upload.service.ts deleted file mode 100644 index b83abf3..0000000 --- a/packages/api/src/upload/upload.service.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { Injectable, Logger, BadRequestException, PayloadTooLargeException } from '@nestjs/common'; -import * as sharp from 'sharp'; -import { StorageService, StorageFile, UploadResult } from '../storage/storage.service'; - -export interface ImageMetadata { - width: number; - height: number; - format: string; - size: number; - hasAlpha: boolean; - density?: number; -} - -export interface ProcessedUpload { - uploadResult: UploadResult; - metadata: ImageMetadata; - originalName: string; - mimeType: string; -} - -export interface UploadQuotaCheck { - allowed: boolean; - remainingQuota: number; - requestedCount: number; - maxFileSize: number; -} - -@Injectable() -export class UploadService { - private readonly logger = new Logger(UploadService.name); - - // File size limits (in bytes) - private readonly MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB - private readonly MAX_TOTAL_SIZE = 500 * 1024 * 1024; // 500MB per batch - - // Quota limits by plan - private readonly QUOTA_LIMITS = { - BASIC: 50, - PRO: 500, - MAX: 1000, - }; - - constructor(private readonly storageService: StorageService) {} - - /** - * Process and upload multiple files - * @param files Array of uploaded files - * @param batchId Batch UUID for organization - * @param keywords Optional keywords for processing - * @returns Array of processed uploads - */ - async processMultipleFiles( - files: Express.Multer.File[], - batchId: string, - keywords?: string[] - ): Promise { - this.logger.log(`Processing ${files.length} files for batch: ${batchId}`); - - // Validate files - this.validateFiles(files); - - const results: ProcessedUpload[] = []; - const duplicateHashes = new Set(); - - for (const file of files) { - try { - // Check for duplicates by checksum - const checksum = this.storageService.calculateChecksum(file.buffer); - if (duplicateHashes.has(checksum)) { - this.logger.warn(`Duplicate file detected: ${file.originalname}`); - continue; - } - duplicateHashes.add(checksum); - - // Process individual file - const processed = await this.processSingleFile(file, batchId, keywords); - results.push(processed); - - } catch (error) { - this.logger.error(`Failed to process file: ${file.originalname}`, error.stack); - // Continue processing other files - } - } - - this.logger.log(`Successfully processed ${results.length}/${files.length} files`); - return results; - } - - /** - * Process a single file upload - * @param file Uploaded file - * @param batchId Batch UUID - * @param keywords Optional keywords - * @returns Processed upload result - */ - async processSingleFile( - file: Express.Multer.File, - batchId: string, - keywords?: string[] - ): Promise { - try { - // Validate file type - if (!this.storageService.isValidImageMimeType(file.mimetype)) { - throw new BadRequestException(`Unsupported file type: ${file.mimetype}`); - } - - // Extract image metadata - const metadata = await this.extractImageMetadata(file.buffer); - - // Create storage file object - const storageFile: StorageFile = { - buffer: file.buffer, - originalName: file.originalname, - mimeType: file.mimetype, - size: file.size, - }; - - // Upload to storage - const uploadResult = await this.storageService.uploadFile(storageFile, batchId); - - this.logger.log(`File processed successfully: ${file.originalname}`); - - return { - uploadResult, - metadata, - originalName: file.originalname, - mimeType: file.mimetype, - }; - - } catch (error) { - this.logger.error(`Failed to process file: ${file.originalname}`, error.stack); - throw error; - } - } - - /** - * Extract image metadata using Sharp - * @param buffer Image buffer - * @returns Image metadata - */ - async extractImageMetadata(buffer: Buffer): Promise { - try { - const image = sharp(buffer); - const metadata = await image.metadata(); - - return { - width: metadata.width || 0, - height: metadata.height || 0, - format: metadata.format || 'unknown', - size: buffer.length, - hasAlpha: metadata.hasAlpha || false, - density: metadata.density, - }; - } catch (error) { - this.logger.error('Failed to extract image metadata', error.stack); - throw new BadRequestException('Invalid image file'); - } - } - - /** - * Validate uploaded files - * @param files Array of files to validate - */ - private validateFiles(files: Express.Multer.File[]): void { - if (!files || files.length === 0) { - throw new BadRequestException('No files provided'); - } - - let totalSize = 0; - - for (const file of files) { - // Check individual file size - if (file.size > this.MAX_FILE_SIZE) { - throw new PayloadTooLargeException( - `File ${file.originalname} exceeds maximum size of ${this.MAX_FILE_SIZE / (1024 * 1024)}MB` - ); - } - - // Check file type - if (!this.storageService.isValidImageMimeType(file.mimetype)) { - throw new BadRequestException( - `Unsupported file type: ${file.mimetype} for file ${file.originalname}` - ); - } - - totalSize += file.size; - } - - // Check total batch size - if (totalSize > this.MAX_TOTAL_SIZE) { - throw new PayloadTooLargeException( - `Total batch size exceeds maximum of ${this.MAX_TOTAL_SIZE / (1024 * 1024)}MB` - ); - } - } - - /** - * Check if user has sufficient quota for upload - * @param fileCount Number of files to upload - * @param userPlan User's subscription plan - * @param remainingQuota User's remaining quota - * @returns Quota check result - */ - checkUploadQuota( - fileCount: number, - userPlan: 'BASIC' | 'PRO' | 'MAX', - remainingQuota: number - ): UploadQuotaCheck { - const maxQuota = this.QUOTA_LIMITS[userPlan]; - const allowed = remainingQuota >= fileCount; - - return { - allowed, - remainingQuota, - requestedCount: fileCount, - maxFileSize: this.MAX_FILE_SIZE, - }; - } - - /** - * Generate thumbnail for image - * @param buffer Original image buffer - * @param width Thumbnail width (default: 200) - * @param height Thumbnail height (default: 200) - * @returns Thumbnail buffer - */ - async generateThumbnail( - buffer: Buffer, - width: number = 200, - height: number = 200 - ): Promise { - try { - return await sharp(buffer) - .resize(width, height, { - fit: 'cover', - position: 'center', - }) - .jpeg({ - quality: 80, - progressive: true, - }) - .toBuffer(); - } catch (error) { - this.logger.error('Failed to generate thumbnail', error.stack); - throw new Error('Thumbnail generation failed'); - } - } - - /** - * Optimize image for web display - * @param buffer Original image buffer - * @param quality JPEG quality (1-100) - * @returns Optimized image buffer - */ - async optimizeImage(buffer: Buffer, quality: number = 85): Promise { - try { - const metadata = await sharp(buffer).metadata(); - - // Skip optimization for very small images - if ((metadata.width || 0) * (metadata.height || 0) < 50000) { - return buffer; - } - - return await sharp(buffer) - .jpeg({ - quality, - progressive: true, - mozjpeg: true, - }) - .toBuffer(); - } catch (error) { - this.logger.error('Failed to optimize image', error.stack); - return buffer; // Return original on error - } - } - - /** - * Validate file against virus/malware (placeholder for future implementation) - * @param buffer File buffer - * @returns Whether file is safe - */ - async validateFileSafety(buffer: Buffer): Promise { - // TODO: Implement virus scanning if needed - // For now, just check if it's a valid image - try { - await sharp(buffer).metadata(); - return true; - } catch { - return false; - } - } - - /** - * Get supported file types - * @returns Array of supported MIME types - */ - getSupportedFileTypes(): string[] { - return [ - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/gif', - 'image/webp', - ]; - } - - /** - * Get file size limits - * @returns File size limits configuration - */ - getFileSizeLimits() { - return { - maxFileSize: this.MAX_FILE_SIZE, - maxTotalSize: this.MAX_TOTAL_SIZE, - maxFileSizeMB: this.MAX_FILE_SIZE / (1024 * 1024), - maxTotalSizeMB: this.MAX_TOTAL_SIZE / (1024 * 1024), - }; - } -} \ No newline at end of file diff --git a/packages/api/src/users/users.controller.ts b/packages/api/src/users/users.controller.ts deleted file mode 100644 index d0848fc..0000000 --- a/packages/api/src/users/users.controller.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { - Controller, - Get, - Put, - Delete, - Body, - Param, - UseGuards, - Req, - HttpStatus, - Logger, -} from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBearerAuth, - ApiParam, -} from '@nestjs/swagger'; - -import { UsersService } from './users.service'; -import { JwtAuthGuard } from '../auth/auth.guard'; -import { - UpdateUserDto, - UserResponseDto, - UserStatsDto -} from './users.entity'; - -export interface AuthenticatedRequest { - user: { - id: string; - email: string; - plan: string; - quotaRemaining: number; - isActive: boolean; - }; -} - -@ApiTags('Users') -@Controller('users') -@UseGuards(JwtAuthGuard) -@ApiBearerAuth() -export class UsersController { - private readonly logger = new Logger(UsersController.name); - - constructor(private readonly usersService: UsersService) {} - - @Get('me') - @ApiOperation({ - summary: 'Get current user profile', - description: 'Returns the authenticated user\'s profile information' - }) - @ApiResponse({ - status: 200, - description: 'User profile retrieved successfully', - type: UserResponseDto - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized' - }) - @ApiResponse({ - status: 404, - description: 'User not found' - }) - async getProfile(@Req() req: AuthenticatedRequest): Promise { - return await this.usersService.getProfile(req.user.id); - } - - @Put('me') - @ApiOperation({ - summary: 'Update current user profile', - description: 'Updates the authenticated user\'s profile information' - }) - @ApiResponse({ - status: 200, - description: 'User profile updated successfully', - type: UserResponseDto - }) - @ApiResponse({ - status: 400, - description: 'Invalid update data' - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized' - }) - @ApiResponse({ - status: 404, - description: 'User not found' - }) - async updateProfile( - @Req() req: AuthenticatedRequest, - @Body() updateData: UpdateUserDto, - ): Promise { - this.logger.log(`User ${req.user.email} updating profile`); - return await this.usersService.updateProfile(req.user.id, updateData); - } - - @Get('me/stats') - @ApiOperation({ - summary: 'Get current user statistics', - description: 'Returns usage statistics for the authenticated user' - }) - @ApiResponse({ - status: 200, - description: 'User statistics retrieved successfully', - type: UserStatsDto - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized' - }) - @ApiResponse({ - status: 404, - description: 'User not found' - }) - async getUserStats(@Req() req: AuthenticatedRequest): Promise { - return await this.usersService.getUserStats(req.user.id); - } - - @Delete('me') - @ApiOperation({ - summary: 'Deactivate current user account', - description: 'Deactivates the authenticated user\'s account (soft delete)' - }) - @ApiResponse({ - status: 200, - description: 'User account deactivated successfully', - type: UserResponseDto - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized' - }) - @ApiResponse({ - status: 404, - description: 'User not found' - }) - async deactivateAccount(@Req() req: AuthenticatedRequest): Promise { - this.logger.log(`User ${req.user.email} deactivating account`); - return await this.usersService.deactivateAccount(req.user.id); - } - - @Put('me/reactivate') - @ApiOperation({ - summary: 'Reactivate current user account', - description: 'Reactivates the authenticated user\'s account' - }) - @ApiResponse({ - status: 200, - description: 'User account reactivated successfully', - type: UserResponseDto - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized' - }) - @ApiResponse({ - status: 404, - description: 'User not found' - }) - async reactivateAccount(@Req() req: AuthenticatedRequest): Promise { - this.logger.log(`User ${req.user.email} reactivating account`); - return await this.usersService.reactivateAccount(req.user.id); - } - - @Get(':id') - @ApiOperation({ - summary: 'Get user by ID', - description: 'Returns user information by ID (admin/internal use)' - }) - @ApiParam({ - name: 'id', - description: 'User unique identifier', - example: '550e8400-e29b-41d4-a716-446655440000' - }) - @ApiResponse({ - status: 200, - description: 'User retrieved successfully', - type: UserResponseDto - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized' - }) - @ApiResponse({ - status: 404, - description: 'User not found' - }) - async findOne(@Param('id') id: string): Promise { - return await this.usersService.findOne(id); - } - - @Get('me/quota/check') - @ApiOperation({ - summary: 'Check user quota availability', - description: 'Checks if the user has sufficient quota for operations' - }) - @ApiResponse({ - status: 200, - description: 'Quota check completed', - schema: { - type: 'object', - properties: { - hasQuota: { type: 'boolean', example: true }, - quotaRemaining: { type: 'number', example: 45 }, - quotaUsed: { type: 'number', example: 5 }, - totalQuota: { type: 'number', example: 50 }, - plan: { type: 'string', example: 'BASIC' }, - } - } - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized' - }) - async checkQuota(@Req() req: AuthenticatedRequest) { - const hasQuota = await this.usersService.hasQuota(req.user.id); - const stats = await this.usersService.getUserStats(req.user.id); - - return { - hasQuota, - quotaRemaining: req.user.quotaRemaining, - quotaUsed: stats.quotaUsed, - totalQuota: stats.totalQuota, - plan: req.user.plan, - }; - } -} \ No newline at end of file diff --git a/packages/api/src/users/users.entity.ts b/packages/api/src/users/users.entity.ts deleted file mode 100644 index cff7320..0000000 --- a/packages/api/src/users/users.entity.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { - IsEmail, - IsString, - IsEnum, - IsInt, - IsBoolean, - IsOptional, - IsUUID, - Min, - IsDate -} from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Plan } from '@prisma/client'; - -export class CreateUserDto { - @ApiPropertyOptional({ - description: 'Google OAuth UID for OAuth integration', - example: 'google_123456789' - }) - @IsOptional() - @IsString() - googleUid?: string; - - @ApiProperty({ - description: 'User email address', - example: 'user@example.com' - }) - @IsEmail() - email: string; - - @ApiProperty({ - description: 'Hashed version of email for privacy', - example: 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3' - }) - @IsString() - emailHash: string; - - @ApiPropertyOptional({ - description: 'User subscription plan', - enum: Plan, - default: Plan.BASIC - }) - @IsOptional() - @IsEnum(Plan) - plan?: Plan; - - @ApiPropertyOptional({ - description: 'Remaining quota for current period', - example: 50, - minimum: 0 - }) - @IsOptional() - @IsInt() - @Min(0) - quotaRemaining?: number; -} - -export class UpdateUserDto { - @ApiPropertyOptional({ - description: 'User subscription plan', - enum: Plan - }) - @IsOptional() - @IsEnum(Plan) - plan?: Plan; - - @ApiPropertyOptional({ - description: 'Remaining quota for current period', - minimum: 0 - }) - @IsOptional() - @IsInt() - @Min(0) - quotaRemaining?: number; - - @ApiPropertyOptional({ - description: 'Whether the user account is active' - }) - @IsOptional() - @IsBoolean() - isActive?: boolean; -} - -export class UserResponseDto { - @ApiProperty({ - description: 'Unique user identifier', - example: '550e8400-e29b-41d4-a716-446655440000' - }) - @IsUUID() - id: string; - - @ApiPropertyOptional({ - description: 'Google OAuth UID', - example: 'google_123456789' - }) - @IsOptional() - @IsString() - googleUid?: string; - - @ApiProperty({ - description: 'User email address', - example: 'user@example.com' - }) - @IsEmail() - email: string; - - @ApiProperty({ - description: 'User subscription plan', - enum: Plan - }) - @IsEnum(Plan) - plan: Plan; - - @ApiProperty({ - description: 'Remaining quota for current period', - example: 50 - }) - @IsInt() - @Min(0) - quotaRemaining: number; - - @ApiProperty({ - description: 'Date when quota resets' - }) - @IsDate() - quotaResetDate: Date; - - @ApiProperty({ - description: 'Whether the user account is active' - }) - @IsBoolean() - isActive: boolean; - - @ApiProperty({ - description: 'User creation timestamp' - }) - @IsDate() - createdAt: Date; - - @ApiProperty({ - description: 'User last update timestamp' - }) - @IsDate() - updatedAt: Date; -} - -export class UserStatsDto { - @ApiProperty({ - description: 'Total number of batches processed' - }) - @IsInt() - @Min(0) - totalBatches: number; - - @ApiProperty({ - description: 'Total number of images processed' - }) - @IsInt() - @Min(0) - totalImages: number; - - @ApiProperty({ - description: 'Current quota usage this period' - }) - @IsInt() - @Min(0) - quotaUsed: number; - - @ApiProperty({ - description: 'Total quota for current plan' - }) - @IsInt() - @Min(0) - totalQuota: number; - - @ApiProperty({ - description: 'Percentage of quota used' - }) - @IsInt() - @Min(0) - quotaUsagePercentage: number; -} - -// Helper function to get quota limits by plan -export function getQuotaLimitForPlan(plan: Plan): number { - switch (plan) { - case Plan.BASIC: - return 50; - case Plan.PRO: - return 500; - case Plan.MAX: - return 1000; - default: - return 50; - } -} - -// Helper function to calculate quota reset date (monthly) -export function calculateQuotaResetDate(): Date { - const now = new Date(); - const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); - return nextMonth; -} \ No newline at end of file diff --git a/packages/api/src/users/users.module.ts b/packages/api/src/users/users.module.ts deleted file mode 100644 index cbd15f1..0000000 --- a/packages/api/src/users/users.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { UsersController } from './users.controller'; -import { UsersService } from './users.service'; -import { DatabaseModule } from '../database/database.module'; - -@Module({ - imports: [DatabaseModule], - controllers: [UsersController], - providers: [UsersService], - exports: [UsersService], -}) -export class UsersModule {} \ No newline at end of file diff --git a/packages/api/src/users/users.service.ts b/packages/api/src/users/users.service.ts deleted file mode 100644 index 78d712e..0000000 --- a/packages/api/src/users/users.service.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { - Injectable, - NotFoundException, - ConflictException, - Logger -} from '@nestjs/common'; -import { User, Plan } from '@prisma/client'; - -import { UserRepository } from '../database/repositories/user.repository'; -import { - CreateUserDto, - UpdateUserDto, - UserResponseDto, - UserStatsDto, - getQuotaLimitForPlan -} from './users.entity'; - -@Injectable() -export class UsersService { - private readonly logger = new Logger(UsersService.name); - - constructor(private readonly userRepository: UserRepository) {} - - /** - * Get user by ID - */ - async findOne(id: string): Promise { - const user = await this.userRepository.findById(id); - if (!user) { - throw new NotFoundException(`User with ID ${id} not found`); - } - - return this.mapToResponseDto(user); - } - - /** - * Get current user profile - */ - async getProfile(userId: string): Promise { - return await this.findOne(userId); - } - - /** - * Update user profile - */ - async updateProfile(userId: string, updateData: UpdateUserDto): Promise { - try { - // Check if user exists - const existingUser = await this.userRepository.findById(userId); - if (!existingUser) { - throw new NotFoundException(`User with ID ${userId} not found`); - } - - // If plan is being updated, adjust quota accordingly - if (updateData.plan && updateData.plan !== existingUser.plan) { - const newQuota = getQuotaLimitForPlan(updateData.plan); - updateData.quotaRemaining = newQuota; - } - - const updatedUser = await this.userRepository.update(userId, updateData); - return this.mapToResponseDto(updatedUser); - } catch (error) { - this.logger.error(`Failed to update user profile ${userId}:`, error); - if (error instanceof NotFoundException) { - throw error; - } - throw new ConflictException('Failed to update user profile'); - } - } - - /** - * Get user statistics - */ - async getUserStats(userId: string): Promise { - try { - const user = await this.userRepository.findByIdWithRelations(userId); - if (!user) { - throw new NotFoundException(`User with ID ${userId} not found`); - } - - const totalQuota = getQuotaLimitForPlan(user.plan); - const quotaUsed = totalQuota - user.quotaRemaining; - const quotaUsagePercentage = Math.round((quotaUsed / totalQuota) * 100); - - return { - totalBatches: user._count.batches, - totalImages: this.calculateTotalImages(user.batches), - quotaUsed, - totalQuota, - quotaUsagePercentage, - }; - } catch (error) { - this.logger.error(`Failed to get user stats for ${userId}:`, error); - if (error instanceof NotFoundException) { - throw error; - } - throw new ConflictException('Failed to retrieve user statistics'); - } - } - - /** - * Deactivate user account - */ - async deactivateAccount(userId: string): Promise { - try { - const updatedUser = await this.userRepository.update(userId, { - isActive: false - }); - return this.mapToResponseDto(updatedUser); - } catch (error) { - this.logger.error(`Failed to deactivate user ${userId}:`, error); - throw new ConflictException('Failed to deactivate user account'); - } - } - - /** - * Reactivate user account - */ - async reactivateAccount(userId: string): Promise { - try { - const updatedUser = await this.userRepository.update(userId, { - isActive: true - }); - return this.mapToResponseDto(updatedUser); - } catch (error) { - this.logger.error(`Failed to reactivate user ${userId}:`, error); - throw new ConflictException('Failed to reactivate user account'); - } - } - - /** - * Check if user has sufficient quota - */ - async hasQuota(userId: string, requiredQuota: number = 1): Promise { - const user = await this.userRepository.findById(userId); - if (!user) { - return false; - } - - return user.quotaRemaining >= requiredQuota && user.isActive; - } - - /** - * Deduct quota from user - */ - async deductQuota(userId: string, amount: number = 1): Promise { - const user = await this.userRepository.findById(userId); - if (!user) { - throw new NotFoundException(`User with ID ${userId} not found`); - } - - if (user.quotaRemaining < amount) { - throw new ConflictException('Insufficient quota remaining'); - } - - return await this.userRepository.deductQuota(userId, amount); - } - - /** - * Reset user quota (for monthly resets) - */ - async resetQuota(userId: string): Promise { - try { - const updatedUser = await this.userRepository.resetQuota(userId); - return this.mapToResponseDto(updatedUser); - } catch (error) { - this.logger.error(`Failed to reset quota for user ${userId}:`, error); - throw new ConflictException('Failed to reset user quota'); - } - } - - /** - * Upgrade user plan - */ - async upgradePlan(userId: string, newPlan: Plan): Promise { - try { - const updatedUser = await this.userRepository.upgradePlan(userId, newPlan); - this.logger.log(`User ${userId} upgraded to ${newPlan} plan`); - return this.mapToResponseDto(updatedUser); - } catch (error) { - this.logger.error(`Failed to upgrade plan for user ${userId}:`, error); - throw new ConflictException('Failed to upgrade user plan'); - } - } - - /** - * Map User entity to UserResponseDto - */ - private mapToResponseDto(user: User): UserResponseDto { - return { - id: user.id, - googleUid: user.googleUid, - email: user.email, - plan: user.plan, - quotaRemaining: user.quotaRemaining, - quotaResetDate: user.quotaResetDate, - isActive: user.isActive, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }; - } - - /** - * Calculate total images processed across all batches - */ - private calculateTotalImages(batches: any[]): number { - return batches.reduce((total, batch) => total + (batch.processedImages || 0), 0); - } -} \ No newline at end of file diff --git a/packages/api/src/websocket/progress.gateway.ts b/packages/api/src/websocket/progress.gateway.ts deleted file mode 100644 index d9f8cc5..0000000 --- a/packages/api/src/websocket/progress.gateway.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { - WebSocketGateway, - WebSocketServer, - SubscribeMessage, - MessageBody, - ConnectedSocket, - OnGatewayConnection, - OnGatewayDisconnect, - OnGatewayInit, -} from '@nestjs/websockets'; -import { Logger, UseGuards } from '@nestjs/common'; -import { Server, Socket } from 'socket.io'; -import { JwtAuthGuard } from '../auth/auth.guard'; -import { QueueService } from '../queue/queue.service'; - -interface ProgressEvent { - image_id: string; - status: 'processing' | 'completed' | 'failed'; - progress?: number; - message?: string; - timestamp: string; -} - -interface ClientConnection { - userId: string; - batchIds: Set; -} - -@WebSocketGateway({ - cors: { - origin: process.env.FRONTEND_URL || 'http://localhost:3000', - credentials: true, - }, - namespace: '/progress', -}) -export class ProgressGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { - @WebSocketServer() - server: Server; - - private readonly logger = new Logger(ProgressGateway.name); - private readonly clients = new Map(); - - constructor(private readonly queueService: QueueService) {} - - afterInit(server: Server) { - this.logger.log('WebSocket Gateway initialized'); - } - - async handleConnection(client: Socket) { - try { - this.logger.log(`Client connected: ${client.id}`); - - // TODO: Implement JWT authentication for WebSocket connections - // For now, we'll extract user info from handshake or query params - const userId = client.handshake.query.userId as string; - - if (!userId) { - this.logger.warn(`Client ${client.id} connected without userId`); - client.disconnect(); - return; - } - - // Store client connection - this.clients.set(client.id, { - userId, - batchIds: new Set(), - }); - - // Send connection confirmation - client.emit('connected', { - message: 'Connected to progress updates', - timestamp: new Date().toISOString(), - }); - - } catch (error) { - this.logger.error(`Error handling connection: ${client.id}`, error.stack); - client.disconnect(); - } - } - - handleDisconnect(client: Socket) { - this.logger.log(`Client disconnected: ${client.id}`); - this.clients.delete(client.id); - } - - /** - * Subscribe to batch progress updates - */ - @SubscribeMessage('subscribe_batch') - async handleSubscribeBatch( - @ConnectedSocket() client: Socket, - @MessageBody() data: { batch_id: string } - ) { - try { - const connection = this.clients.get(client.id); - if (!connection) { - client.emit('error', { message: 'Connection not found' }); - return; - } - - const { batch_id: batchId } = data; - if (!batchId) { - client.emit('error', { message: 'batch_id is required' }); - return; - } - - // Add batch to client's subscriptions - connection.batchIds.add(batchId); - - // Join the batch room - await client.join(`batch:${batchId}`); - - this.logger.log(`Client ${client.id} subscribed to batch: ${batchId}`); - - // Send confirmation - client.emit('subscribed', { - batch_id: batchId, - message: 'Subscribed to batch progress updates', - timestamp: new Date().toISOString(), - }); - - // Send initial batch status - await this.sendBatchStatus(batchId, client); - - } catch (error) { - this.logger.error(`Error subscribing to batch: ${client.id}`, error.stack); - client.emit('error', { message: 'Failed to subscribe to batch' }); - } - } - - /** - * Unsubscribe from batch progress updates - */ - @SubscribeMessage('unsubscribe_batch') - async handleUnsubscribeBatch( - @ConnectedSocket() client: Socket, - @MessageBody() data: { batch_id: string } - ) { - try { - const connection = this.clients.get(client.id); - if (!connection) { - return; - } - - const { batch_id: batchId } = data; - if (!batchId) { - client.emit('error', { message: 'batch_id is required' }); - return; - } - - // Remove batch from client's subscriptions - connection.batchIds.delete(batchId); - - // Leave the batch room - await client.leave(`batch:${batchId}`); - - this.logger.log(`Client ${client.id} unsubscribed from batch: ${batchId}`); - - client.emit('unsubscribed', { - batch_id: batchId, - message: 'Unsubscribed from batch progress updates', - timestamp: new Date().toISOString(), - }); - - } catch (error) { - this.logger.error(`Error unsubscribing from batch: ${client.id}`, error.stack); - client.emit('error', { message: 'Failed to unsubscribe from batch' }); - } - } - - /** - * Get current batch status - */ - @SubscribeMessage('get_batch_status') - async handleGetBatchStatus( - @ConnectedSocket() client: Socket, - @MessageBody() data: { batch_id: string } - ) { - try { - const { batch_id: batchId } = data; - if (!batchId) { - client.emit('error', { message: 'batch_id is required' }); - return; - } - - await this.sendBatchStatus(batchId, client); - - } catch (error) { - this.logger.error(`Error getting batch status: ${client.id}`, error.stack); - client.emit('error', { message: 'Failed to get batch status' }); - } - } - - /** - * Broadcast progress update to all clients subscribed to a batch - */ - broadcastBatchProgress(batchId: string, progress: { - state: 'PROCESSING' | 'DONE' | 'ERROR'; - progress: number; - processedImages?: number; - totalImages?: number; - currentImage?: string; - }) { - try { - const event = { - batch_id: batchId, - ...progress, - timestamp: new Date().toISOString(), - }; - - this.server.to(`batch:${batchId}`).emit('batch_progress', event); - - this.logger.debug(`Broadcasted batch progress: ${batchId} - ${progress.progress}%`); - - } catch (error) { - this.logger.error(`Error broadcasting batch progress: ${batchId}`, error.stack); - } - } - - /** - * Broadcast image-specific progress update - */ - broadcastImageProgress(batchId: string, imageId: string, status: 'processing' | 'completed' | 'failed', message?: string) { - try { - const event: ProgressEvent = { - image_id: imageId, - status, - message, - timestamp: new Date().toISOString(), - }; - - this.server.to(`batch:${batchId}`).emit('image_progress', event); - - this.logger.debug(`Broadcasted image progress: ${imageId} - ${status}`); - - } catch (error) { - this.logger.error(`Error broadcasting image progress: ${imageId}`, error.stack); - } - } - - /** - * Broadcast batch completion - */ - broadcastBatchCompleted(batchId: string, summary: { - totalImages: number; - processedImages: number; - failedImages: number; - processingTime: number; - }) { - try { - const event = { - batch_id: batchId, - state: 'DONE', - progress: 100, - ...summary, - timestamp: new Date().toISOString(), - }; - - this.server.to(`batch:${batchId}`).emit('batch_completed', event); - - this.logger.log(`Broadcasted batch completion: ${batchId}`); - - } catch (error) { - this.logger.error(`Error broadcasting batch completion: ${batchId}`, error.stack); - } - } - - /** - * Broadcast batch error - */ - broadcastBatchError(batchId: string, error: string) { - try { - const event = { - batch_id: batchId, - state: 'ERROR', - progress: 0, - error, - timestamp: new Date().toISOString(), - }; - - this.server.to(`batch:${batchId}`).emit('batch_error', event); - - this.logger.log(`Broadcasted batch error: ${batchId}`); - - } catch (error) { - this.logger.error(`Error broadcasting batch error: ${batchId}`, error.stack); - } - } - - /** - * Send current batch status to a specific client - */ - private async sendBatchStatus(batchId: string, client: Socket) { - try { - // TODO: Get actual batch status from database - // For now, we'll send a mock status - - const mockStatus = { - batch_id: batchId, - state: 'PROCESSING' as const, - progress: 45, - processedImages: 4, - totalImages: 10, - timestamp: new Date().toISOString(), - }; - - client.emit('batch_status', mockStatus); - - } catch (error) { - this.logger.error(`Error sending batch status: ${batchId}`, error.stack); - client.emit('error', { message: 'Failed to get batch status' }); - } - } - - /** - * Get connected clients count for monitoring - */ - getConnectedClientsCount(): number { - return this.clients.size; - } - - /** - * Get subscriptions count for a specific batch - */ - getBatchSubscriptionsCount(batchId: string): number { - let count = 0; - for (const connection of this.clients.values()) { - if (connection.batchIds.has(batchId)) { - count++; - } - } - return count; - } - - /** - * Cleanup inactive connections (can be called periodically) - */ - cleanupInactiveConnections() { - const inactiveClients: string[] = []; - - for (const [clientId, connection] of this.clients.entries()) { - const socket = this.server.sockets.sockets.get(clientId); - if (!socket || !socket.connected) { - inactiveClients.push(clientId); - } - } - - for (const clientId of inactiveClients) { - this.clients.delete(clientId); - } - - if (inactiveClients.length > 0) { - this.logger.log(`Cleaned up ${inactiveClients.length} inactive connections`); - } - } -} \ No newline at end of file diff --git a/packages/api/src/websocket/websocket.module.ts b/packages/api/src/websocket/websocket.module.ts deleted file mode 100644 index 0c040a3..0000000 --- a/packages/api/src/websocket/websocket.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ProgressGateway } from './progress.gateway'; -import { QueueModule } from '../queue/queue.module'; - -@Module({ - imports: [QueueModule], - providers: [ProgressGateway], - exports: [ProgressGateway], -}) -export class WebSocketModule {} \ No newline at end of file diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json deleted file mode 100644 index febf7a2..0000000 --- a/packages/api/tsconfig.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "strict": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noImplicitOverride": true, - "exactOptionalPropertyTypes": true, - "noUncheckedIndexedAccess": true, - "paths": { - "@/*": ["src/*"], - "@/database/*": ["src/database/*"], - "@/users/*": ["src/users/*"], - "@/batches/*": ["src/batches/*"], - "@/images/*": ["src/images/*"], - "@/payments/*": ["src/payments/*"], - "@/auth/*": ["src/auth/*"], - "@/common/*": ["src/common/*"] - } - }, - "include": [ - "src/**/*", - "prisma/**/*" - ], - "exclude": [ - "node_modules", - "dist", - "test", - "**/*.spec.ts" - ] -} \ No newline at end of file diff --git a/packages/frontend/api.js b/packages/frontend/api.js deleted file mode 100644 index d3f529d..0000000 --- a/packages/frontend/api.js +++ /dev/null @@ -1,298 +0,0 @@ -/** - * API Service for handling all backend communication - */ -class APIService { - constructor() { - this.baseURL = CONFIG.API_BASE_URL; - this.token = localStorage.getItem(CONFIG.STORAGE_KEYS.AUTH_TOKEN); - } - - /** - * Set authentication token - */ - setToken(token) { - this.token = token; - if (token) { - localStorage.setItem(CONFIG.STORAGE_KEYS.AUTH_TOKEN, token); - } else { - localStorage.removeItem(CONFIG.STORAGE_KEYS.AUTH_TOKEN); - } - } - - /** - * Get authentication headers - */ - getHeaders() { - const headers = { - 'Content-Type': 'application/json', - }; - - if (this.token) { - headers['Authorization'] = `Bearer ${this.token}`; - } - - return headers; - } - - /** - * Make API request - */ - async request(endpoint, options = {}) { - const url = `${this.baseURL}${endpoint}`; - const config = { - headers: this.getHeaders(), - ...options, - }; - - try { - const response = await fetch(url, config); - - if (response.status === 401) { - // Token expired or invalid - this.setToken(null); - throw new Error('Authentication required'); - } - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `HTTP ${response.status}`); - } - - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - return await response.json(); - } - - return response; - } catch (error) { - console.error('API Request Error:', error); - throw error; - } - } - - /** - * GET request - */ - async get(endpoint) { - return this.request(endpoint, { method: 'GET' }); - } - - /** - * POST request - */ - async post(endpoint, data) { - return this.request(endpoint, { - method: 'POST', - body: JSON.stringify(data), - }); - } - - /** - * PUT request - */ - async put(endpoint, data) { - return this.request(endpoint, { - method: 'PUT', - body: JSON.stringify(data), - }); - } - - /** - * DELETE request - */ - async delete(endpoint) { - return this.request(endpoint, { method: 'DELETE' }); - } - - /** - * Upload files with FormData - */ - async upload(endpoint, formData, onProgress = null) { - const url = `${this.baseURL}${endpoint}`; - - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - // Track upload progress - if (onProgress) { - xhr.upload.addEventListener('progress', (event) => { - if (event.lengthComputable) { - const percentComplete = (event.loaded / event.total) * 100; - onProgress(percentComplete); - } - }); - } - - xhr.addEventListener('load', () => { - if (xhr.status >= 200 && xhr.status < 300) { - try { - const response = JSON.parse(xhr.responseText); - resolve(response); - } catch (error) { - resolve(xhr.responseText); - } - } else { - reject(new Error(`Upload failed: ${xhr.status}`)); - } - }); - - xhr.addEventListener('error', () => { - reject(new Error('Upload failed')); - }); - - xhr.open('POST', url); - - // Set auth header - if (this.token) { - xhr.setRequestHeader('Authorization', `Bearer ${this.token}`); - } - - xhr.send(formData); - }); - } - - // Auth API methods - async getProfile() { - return this.get(CONFIG.ENDPOINTS.ME); - } - - async logout() { - const result = await this.post(CONFIG.ENDPOINTS.LOGOUT); - this.setToken(null); - return result; - } - - // User API methods - async getUserStats() { - return this.get(CONFIG.ENDPOINTS.USER_STATS); - } - - async getUserQuota() { - return this.get(CONFIG.ENDPOINTS.USER_QUOTA); - } - - // Batch API methods - async createBatch(data) { - return this.post(CONFIG.ENDPOINTS.BATCHES, data); - } - - async getBatch(batchId) { - return this.get(CONFIG.ENDPOINTS.BATCHES.replace(':id', batchId)); - } - - async getBatchStatus(batchId) { - return this.get(CONFIG.ENDPOINTS.BATCH_STATUS.replace(':id', batchId)); - } - - async getBatchImages(batchId) { - return this.get(CONFIG.ENDPOINTS.BATCH_IMAGES.replace(':id', batchId)); - } - - async getBatches(page = 1, limit = 10) { - return this.get(`${CONFIG.ENDPOINTS.BATCHES}?page=${page}&limit=${limit}`); - } - - // Image API methods - async uploadImages(files, batchId, onProgress = null) { - const formData = new FormData(); - formData.append('batchId', batchId); - - files.forEach((file, index) => { - formData.append('images', file); - }); - - return this.upload(CONFIG.ENDPOINTS.IMAGE_UPLOAD, formData, onProgress); - } - - async updateImageFilename(imageId, filename) { - return this.put(CONFIG.ENDPOINTS.IMAGE_UPDATE.replace(':id', imageId), { - filename, - }); - } - - // Keyword API methods - async enhanceKeywords(keywords) { - return this.post(CONFIG.ENDPOINTS.KEYWORD_ENHANCE, { keywords }); - } - - // Payment API methods - async getPlans() { - return this.get(CONFIG.ENDPOINTS.PAYMENT_PLANS); - } - - async getSubscription() { - return this.get(CONFIG.ENDPOINTS.PAYMENT_SUBSCRIPTION); - } - - async createCheckoutSession(plan, successUrl, cancelUrl) { - return this.post(CONFIG.ENDPOINTS.PAYMENT_CHECKOUT, { - plan, - successUrl, - cancelUrl, - }); - } - - async createPortalSession(returnUrl) { - return this.post(CONFIG.ENDPOINTS.PAYMENT_PORTAL, { - returnUrl, - }); - } - - async cancelSubscription() { - return this.post('/api/payments/cancel-subscription'); - } - - async upgradePlan(plan, successUrl, cancelUrl) { - return this.post('/api/payments/upgrade', { - plan, - successUrl, - cancelUrl, - }); - } - - // Download API methods - async createDownload(batchId) { - return this.post(CONFIG.ENDPOINTS.DOWNLOAD_CREATE, { batchId }); - } - - async getDownloadStatus(downloadId) { - return this.get(CONFIG.ENDPOINTS.DOWNLOAD_STATUS.replace(':id', downloadId)); - } - - async getDownloadHistory() { - return this.get(CONFIG.ENDPOINTS.DOWNLOAD_HISTORY); - } - - getDownloadUrl(downloadId) { - return `${this.baseURL}${CONFIG.ENDPOINTS.DOWNLOAD_FILE.replace(':id', downloadId)}`; - } - - // Utility methods - buildUrl(endpoint, params = {}) { - let url = endpoint; - Object.keys(params).forEach(key => { - url = url.replace(`:${key}`, params[key]); - }); - return url; - } - - async healthCheck() { - try { - await this.get('/api/health'); - return true; - } catch (error) { - return false; - } - } -} - -// Create global API instance -const API = new APIService(); - -// Export for use in other modules -if (typeof module !== 'undefined' && module.exports) { - module.exports = { APIService, API }; -} else if (typeof window !== 'undefined') { - window.API = API; - window.APIService = APIService; -} \ No newline at end of file diff --git a/packages/frontend/config.js b/packages/frontend/config.js deleted file mode 100644 index 34f6e4f..0000000 --- a/packages/frontend/config.js +++ /dev/null @@ -1,195 +0,0 @@ -// Configuration for the frontend application -const CONFIG = { - // API Configuration - API_BASE_URL: process.env.NODE_ENV === 'production' - ? 'https://api.seo-image-renamer.com' - : 'http://localhost:3001', - - // WebSocket Configuration - WEBSOCKET_URL: process.env.NODE_ENV === 'production' - ? 'wss://api.seo-image-renamer.com' - : 'ws://localhost:3001', - - // Stripe Configuration - STRIPE_PUBLISHABLE_KEY: process.env.NODE_ENV === 'production' - ? 'pk_live_your_stripe_publishable_key' - : 'pk_test_51234567890abcdef', - - // Google OAuth Configuration - GOOGLE_CLIENT_ID: process.env.NODE_ENV === 'production' - ? 'your-production-google-client-id.apps.googleusercontent.com' - : 'your-dev-google-client-id.apps.googleusercontent.com', - - // Upload Configuration - MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB - MAX_FILES: 50, - SUPPORTED_FORMATS: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], - - // Processing Configuration - WEBSOCKET_RECONNECT_INTERVAL: 5000, - MAX_RECONNECT_ATTEMPTS: 5, - - // UI Configuration - ANIMATION_DURATION: 300, - TOAST_DURATION: 5000, - - // Feature Flags - FEATURES: { - GOOGLE_AUTH: true, - STRIPE_PAYMENTS: true, - WEBSOCKET_UPDATES: true, - IMAGE_PREVIEW: true, - BATCH_PROCESSING: true, - DOWNLOAD_TRACKING: true, - }, - - // Error Messages - ERRORS: { - NETWORK_ERROR: 'Network error. Please check your connection and try again.', - AUTH_REQUIRED: 'Please sign in to continue.', - QUOTA_EXCEEDED: 'You have reached your monthly quota. Please upgrade your plan.', - FILE_TOO_LARGE: 'File is too large. Maximum size is 10MB.', - UNSUPPORTED_FORMAT: 'Unsupported file format. Please use JPG, PNG, WebP, or GIF.', - TOO_MANY_FILES: 'Too many files. Maximum is 50 files per batch.', - PROCESSING_FAILED: 'Processing failed. Please try again.', - DOWNLOAD_FAILED: 'Download failed. Please try again.', - }, - - // Success Messages - SUCCESS: { - UPLOAD_COMPLETE: 'Files uploaded successfully!', - PROCESSING_COMPLETE: 'Images processed successfully!', - DOWNLOAD_READY: 'Your download is ready!', - PAYMENT_SUCCESS: 'Payment successful! Your plan has been upgraded.', - KEYWORDS_ENHANCED: 'Keywords enhanced successfully!', - }, - - // API Endpoints - ENDPOINTS: { - // Auth - GOOGLE_AUTH: '/api/auth/google', - LOGIN: '/api/auth/login', - LOGOUT: '/api/auth/logout', - ME: '/api/auth/me', - - // Users - USER_PROFILE: '/api/users/profile', - USER_STATS: '/api/users/stats', - USER_QUOTA: '/api/users/quota', - - // Batches - BATCHES: '/api/batches', - BATCH_STATUS: '/api/batches/:id/status', - BATCH_IMAGES: '/api/batches/:id/images', - - // Images - IMAGES: '/api/images', - IMAGE_UPLOAD: '/api/images/upload', - IMAGE_UPDATE: '/api/images/:id', - - // Keywords - KEYWORD_ENHANCE: '/api/keywords/enhance', - - // Payments - PAYMENT_CHECKOUT: '/api/payments/checkout', - PAYMENT_PORTAL: '/api/payments/portal', - PAYMENT_SUBSCRIPTION: '/api/payments/subscription', - PAYMENT_PLANS: '/api/payments/plans', - - // Downloads - DOWNLOAD_CREATE: '/api/downloads/create', - DOWNLOAD_STATUS: '/api/downloads/:id/status', - DOWNLOAD_FILE: '/api/downloads/:id', - DOWNLOAD_HISTORY: '/api/downloads/user/history', - }, - - // WebSocket Events - WEBSOCKET_EVENTS: { - // Connection - CONNECT: 'connect', - DISCONNECT: 'disconnect', - ERROR: 'error', - - // Batch Processing - BATCH_CREATED: 'batch.created', - BATCH_UPDATED: 'batch.updated', - BATCH_COMPLETED: 'batch.completed', - BATCH_FAILED: 'batch.failed', - - // Image Processing - IMAGE_PROCESSING: 'image.processing', - IMAGE_COMPLETED: 'image.completed', - IMAGE_FAILED: 'image.failed', - - // Progress Updates - PROGRESS_UPDATE: 'progress.update', - - // User Updates - QUOTA_UPDATED: 'quota.updated', - SUBSCRIPTION_UPDATED: 'subscription.updated', - }, - - // Local Storage Keys - STORAGE_KEYS: { - AUTH_TOKEN: 'seo_auth_token', - USER_DATA: 'seo_user_data', - RECENT_KEYWORDS: 'seo_recent_keywords', - UPLOAD_PROGRESS: 'seo_upload_progress', - BATCH_DATA: 'seo_batch_data', - }, - - // URLs - URLS: { - TERMS_OF_SERVICE: '/terms', - PRIVACY_POLICY: '/privacy', - SUPPORT: '/support', - DOCUMENTATION: '/docs', - }, - - // Quota Limits by Plan - PLAN_LIMITS: { - BASIC: 50, - PRO: 500, - MAX: 1000, - }, - - // Plan Prices (in cents) - PLAN_PRICES: { - BASIC: 0, - PRO: 900, // $9.00 - MAX: 1900, // $19.00 - }, - - // Image Processing Settings - IMAGE_PROCESSING: { - MAX_FILENAME_LENGTH: 100, - MIN_KEYWORDS: 1, - MAX_KEYWORDS: 10, - SUPPORTED_EXTENSIONS: ['.jpg', '.jpeg', '.png', '.webp', '.gif'], - }, - - // Development Settings - DEV: { - ENABLE_LOGGING: true, - MOCK_API_DELAY: 1000, - ENABLE_DEBUG_MODE: process.env.NODE_ENV === 'development', - }, -}; - -// Environment-specific overrides -if (typeof window !== 'undefined') { - // Browser environment - const hostname = window.location.hostname; - - if (hostname === 'localhost' || hostname === '127.0.0.1') { - CONFIG.API_BASE_URL = 'http://localhost:3001'; - CONFIG.WEBSOCKET_URL = 'ws://localhost:3001'; - } -} - -// Export configuration -if (typeof module !== 'undefined' && module.exports) { - module.exports = CONFIG; -} else if (typeof window !== 'undefined') { - window.CONFIG = CONFIG; -} \ No newline at end of file diff --git a/packages/frontend/index.html b/packages/frontend/index.html deleted file mode 100644 index 5c9dafe..0000000 --- a/packages/frontend/index.html +++ /dev/null @@ -1,476 +0,0 @@ - - - - - - SEO Image Renamer - AI-Powered Image SEO Tool - - - - - - - - - - - -
-
- - -
- -
-
-
- -
- - - - -
-
-
-
-
- - AI-Powered -
-

Save time! Bulk rename your images individually for better SEO performance

-

Transform your image SEO workflow with AI that analyzes content and generates perfect filenames automatically. No more manual renaming - just upload, enhance, and download.

- -
-
- - AI Vision Analysis -
-
- - Smart Keyword Enhancement -
-
- - Instant ZIP Download -
-
- -
-
- 10k+ - Images Processed -
-
- 95% - Time Saved -
-
-
- -
-
-
-
- -
-

Drop your images here

-

or click to browse files

- - -
- Supports: JPG, PNG, WEBP, GIF -
-
-
-
-
-
-
- - - - - -
-
-
-

Powerful Features for Better SEO

-

Everything you need to optimize your images for search engines

-
- -
-
-
- -
-

AI-Powered Naming

-

Advanced AI generates SEO-friendly filenames that help your images rank higher in search results.

-
- -
-
- -
-

Image Recognition

-

AI analyzes your images to understand content and context for more accurate naming.

-
- -
-
- -
-

Keyword Enhancement

-

Enhance your keywords with AI-suggested synonyms for better SEO performance.

-
- -
-
- -
-

Easy Download

-

Download all your renamed images in a single ZIP file with preserved EXIF data.

-
-
-
-
- - -
-
-
-

How It Works

-

Get better SEO for your images in just three simple steps

-
- -
-
-
1
-

Upload Images

-

Drag and drop your images or browse your files to upload them to our platform.

-
- -
-
2
-

Add Keywords

-

Provide keywords that describe your images, or let our AI enhance them for better SEO.

-
- -
-
3
-

Download & Implement

-

Download your renamed images as a ZIP file and use them on your website.

-
-
-
-
- - -
-
-
-

Simple, Transparent Pricing

-

Choose the plan that works best for you

-
- -
-
-

Basic

-
$0/month
-
    -
  • 50 images per month
  • -
  • AI-powered naming
  • -
  • Keyword enhancement
  • -
  • ZIP download
  • -
- -
- - - -
-

Max

-
$19/month
-
    -
  • 1000 images per month
  • -
  • AI-powered naming
  • -
  • Keyword enhancement
  • -
  • ZIP download
  • -
  • Priority support
  • -
  • Advanced analytics
  • -
- -
-
-
-
-
- - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 8952d9e..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "compilerOptions": { - // Language and Environment - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "allowJs": true, - "checkJs": false, - - // Bundler mode - "allowImportingTsExtensions": false, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": false, - - // Type Checking - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "exactOptionalPropertyTypes": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "allowUnusedLabels": false, - "allowUnreachableCode": false, - - // Modules - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - - // Emit - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "removeComments": false, - "importHelpers": true, - - // Interop Constraints - "verbatimModuleSyntax": false, - - // JavaScript Support - "allowJs": true, - "checkJs": false, - - // Editor Support - "plugins": [ - { - "name": "typescript-plugin-css-modules" - } - ], - - // Path Mapping - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"], - "@api/*": ["./packages/api/src/*"], - "@worker/*": ["./packages/worker/src/*"], - "@frontend/*": ["./packages/frontend/src/*"], - "@shared/*": ["./packages/shared/src/*"], - "@types/*": ["./types/*"], - "@utils/*": ["./packages/shared/src/utils/*"], - "@config/*": ["./packages/shared/src/config/*"] - }, - - // Advanced - "skipLibCheck": true, - "incremental": true, - "tsBuildInfoFile": "./dist/.tsbuildinfo" - }, - "include": [ - "packages/**/*", - "types/**/*", - "*.ts", - "*.js" - ], - "exclude": [ - "node_modules", - "dist", - "build", - "coverage", - "**/*.spec.ts", - "**/*.test.ts", - "**/*.stories.ts", - "**/*.stories.tsx" - ], - "references": [ - { - "path": "./packages/api" - }, - { - "path": "./packages/worker" - }, - { - "path": "./packages/frontend" - }, - { - "path": "./packages/shared" - } - ], - "ts-node": { - "esm": true, - "experimentalSpecifierResolution": "node", - "compilerOptions": { - "module": "ESNext", - "moduleResolution": "node" - } - } -} \ No newline at end of file