Compare commits
No commits in common. "d53cbb6757e511c7b54af7cbdcd23bf29272e0ea" and "90016254a9281b88d2d999aa094758f3dc9b3b53" have entirely different histories.
d53cbb6757
...
90016254a9
98 changed files with 0 additions and 18037 deletions
240
.env.example
240
.env.example
|
|
@ -1,240 +0,0 @@
|
||||||
# AI Bulk Image Renamer - Environment Variables Template
|
|
||||||
# Copy this file to .env and update with your actual values
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# APPLICATION CONFIGURATION
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Environment (development, staging, production)
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Application
|
|
||||||
APP_NAME="AI Bulk Image Renamer"
|
|
||||||
APP_VERSION=1.0.0
|
|
||||||
APP_URL=http://localhost:3000
|
|
||||||
APP_PORT=3000
|
|
||||||
API_PORT=3001
|
|
||||||
|
|
||||||
# Application Security
|
|
||||||
APP_SECRET=your_super_secret_key_change_this_in_production
|
|
||||||
JWT_SECRET=your_jwt_secret_key_minimum_32_characters
|
|
||||||
JWT_EXPIRES_IN=7d
|
|
||||||
JWT_REFRESH_EXPIRES_IN=30d
|
|
||||||
|
|
||||||
# Session Configuration
|
|
||||||
SESSION_SECRET=your_session_secret_key
|
|
||||||
SESSION_MAX_AGE=86400000
|
|
||||||
|
|
||||||
# CORS Settings
|
|
||||||
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
|
|
||||||
CORS_CREDENTIALS=true
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# DATABASE CONFIGURATION
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# PostgreSQL Database
|
|
||||||
DATABASE_URL=postgresql://postgres:dev_password_123@localhost:5432/ai_image_renamer_dev
|
|
||||||
POSTGRES_HOST=localhost
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
POSTGRES_DB=ai_image_renamer_dev
|
|
||||||
POSTGRES_USER=postgres
|
|
||||||
POSTGRES_PASSWORD=dev_password_123
|
|
||||||
|
|
||||||
# Database Pool Settings
|
|
||||||
DB_POOL_MIN=2
|
|
||||||
DB_POOL_MAX=10
|
|
||||||
DB_POOL_IDLE_TIMEOUT=30000
|
|
||||||
DB_POOL_ACQUIRE_TIMEOUT=60000
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# REDIS CONFIGURATION
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Redis Cache & Queues
|
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
REDIS_HOST=localhost
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=
|
|
||||||
REDIS_DB=0
|
|
||||||
|
|
||||||
# Redis Queue Settings
|
|
||||||
REDIS_QUEUE_DB=1
|
|
||||||
REDIS_SESSION_DB=2
|
|
||||||
REDIS_CACHE_DB=3
|
|
||||||
|
|
||||||
# Cache Settings
|
|
||||||
CACHE_TTL=3600
|
|
||||||
CACHE_MAX_ITEMS=1000
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# OBJECT STORAGE (MinIO/S3)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# MinIO Configuration
|
|
||||||
MINIO_ENDPOINT=localhost:9000
|
|
||||||
MINIO_ACCESS_KEY=minio_dev_user
|
|
||||||
MINIO_SECRET_KEY=minio_dev_password_123
|
|
||||||
MINIO_USE_SSL=false
|
|
||||||
MINIO_PORT=9000
|
|
||||||
|
|
||||||
# S3 Buckets
|
|
||||||
S3_BUCKET_IMAGES=images
|
|
||||||
S3_BUCKET_PROCESSED=processed
|
|
||||||
S3_BUCKET_TEMP=temp
|
|
||||||
S3_REGION=us-east-1
|
|
||||||
|
|
||||||
# File Upload Settings
|
|
||||||
MAX_FILE_SIZE=50MB
|
|
||||||
ALLOWED_IMAGE_TYPES=jpg,jpeg,png,webp,gif,bmp,tiff
|
|
||||||
MAX_FILES_PER_BATCH=100
|
|
||||||
UPLOAD_TIMEOUT=300000
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# AI & PROCESSING CONFIGURATION
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# OpenAI Configuration
|
|
||||||
OPENAI_API_KEY=sk-your_openai_api_key_here
|
|
||||||
OPENAI_MODEL=gpt-4
|
|
||||||
OPENAI_MAX_TOKENS=150
|
|
||||||
OPENAI_TEMPERATURE=0.3
|
|
||||||
|
|
||||||
# Alternative AI Providers (optional)
|
|
||||||
ANTHROPIC_API_KEY=
|
|
||||||
GOOGLE_AI_API_KEY=
|
|
||||||
AZURE_OPENAI_ENDPOINT=
|
|
||||||
AZURE_OPENAI_API_KEY=
|
|
||||||
|
|
||||||
# Image Processing
|
|
||||||
IMAGE_QUALITY=85
|
|
||||||
IMAGE_MAX_WIDTH=2048
|
|
||||||
IMAGE_MAX_HEIGHT=2048
|
|
||||||
THUMBNAIL_SIZE=300
|
|
||||||
WATERMARK_ENABLED=false
|
|
||||||
|
|
||||||
# Processing Limits
|
|
||||||
MAX_CONCURRENT_JOBS=5
|
|
||||||
JOB_TIMEOUT=600000
|
|
||||||
RETRY_ATTEMPTS=3
|
|
||||||
RETRY_DELAY=5000
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# SECURITY & ANTIVIRUS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ClamAV Antivirus
|
|
||||||
CLAMAV_HOST=localhost
|
|
||||||
CLAMAV_PORT=3310
|
|
||||||
CLAMAV_TIMEOUT=30000
|
|
||||||
VIRUS_SCAN_ENABLED=true
|
|
||||||
|
|
||||||
# Rate Limiting
|
|
||||||
RATE_LIMIT_WINDOW=900000
|
|
||||||
RATE_LIMIT_MAX_REQUESTS=100
|
|
||||||
RATE_LIMIT_SKIP_SUCCESSFUL=true
|
|
||||||
|
|
||||||
# Security Headers
|
|
||||||
SECURITY_HSTS_MAX_AGE=31536000
|
|
||||||
SECURITY_CONTENT_TYPE_NOSNIFF=true
|
|
||||||
SECURITY_FRAME_OPTIONS=DENY
|
|
||||||
SECURITY_XSS_PROTECTION=true
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# EMAIL CONFIGURATION
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# SMTP Settings
|
|
||||||
SMTP_HOST=localhost
|
|
||||||
SMTP_PORT=1025
|
|
||||||
SMTP_SECURE=false
|
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASS=
|
|
||||||
|
|
||||||
# Email Settings
|
|
||||||
EMAIL_FROM="AI Image Renamer <noreply@example.com>"
|
|
||||||
EMAIL_REPLY_TO=support@example.com
|
|
||||||
ADMIN_EMAIL=admin@example.com
|
|
||||||
|
|
||||||
# Email Templates
|
|
||||||
EMAIL_VERIFICATION_ENABLED=true
|
|
||||||
EMAIL_NOTIFICATIONS_ENABLED=true
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# LOGGING & MONITORING
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Logging Configuration
|
|
||||||
LOG_LEVEL=info
|
|
||||||
LOG_FORMAT=combined
|
|
||||||
LOG_FILE_ENABLED=true
|
|
||||||
LOG_FILE_PATH=./logs/app.log
|
|
||||||
LOG_MAX_SIZE=10MB
|
|
||||||
LOG_MAX_FILES=5
|
|
||||||
|
|
||||||
# Monitoring
|
|
||||||
HEALTH_CHECK_ENABLED=true
|
|
||||||
METRICS_ENABLED=true
|
|
||||||
METRICS_PORT=9090
|
|
||||||
|
|
||||||
# Sentry Error Tracking (optional)
|
|
||||||
SENTRY_DSN=
|
|
||||||
SENTRY_ENVIRONMENT=development
|
|
||||||
SENTRY_RELEASE=
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# BUSINESS LOGIC CONFIGURATION
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# User Limits
|
|
||||||
FREE_TIER_MONTHLY_LIMIT=100
|
|
||||||
PREMIUM_TIER_MONTHLY_LIMIT=10000
|
|
||||||
MAX_API_CALLS_PER_MINUTE=10
|
|
||||||
|
|
||||||
# SEO Settings
|
|
||||||
SEO_MIN_FILENAME_LENGTH=10
|
|
||||||
SEO_MAX_FILENAME_LENGTH=100
|
|
||||||
SEO_INCLUDE_ALT_TEXT=true
|
|
||||||
SEO_INCLUDE_KEYWORDS=true
|
|
||||||
|
|
||||||
# Subscription & Billing (Stripe)
|
|
||||||
STRIPE_PUBLIC_KEY=pk_test_your_stripe_public_key
|
|
||||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# EXTERNAL SERVICES
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Google Analytics
|
|
||||||
GA_TRACKING_ID=
|
|
||||||
GA_MEASUREMENT_ID=
|
|
||||||
|
|
||||||
# Social Login (optional)
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
GITHUB_CLIENT_ID=
|
|
||||||
GITHUB_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# CDN Configuration
|
|
||||||
CDN_URL=
|
|
||||||
CDN_ENABLED=false
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# DEVELOPMENT & TESTING
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Development Settings
|
|
||||||
ENABLE_CORS=true
|
|
||||||
ENABLE_SWAGGER=true
|
|
||||||
ENABLE_PLAYGROUND=true
|
|
||||||
ENABLE_DEBUG_LOGS=true
|
|
||||||
|
|
||||||
# Test Database (for running tests)
|
|
||||||
TEST_DATABASE_URL=postgresql://postgres:test_password@localhost:5432/ai_image_renamer_test
|
|
||||||
|
|
||||||
# Feature Flags
|
|
||||||
FEATURE_BATCH_PROCESSING=true
|
|
||||||
FEATURE_AI_SUGGESTIONS=true
|
|
||||||
FEATURE_BULK_OPERATIONS=true
|
|
||||||
FEATURE_ANALYTICS=false
|
|
||||||
204
.eslintrc.js
204
.eslintrc.js
|
|
@ -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'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
@ -1,397 +0,0 @@
|
||||||
name: CI Pipeline
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, develop, 'feature/*', 'hotfix/*' ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main, develop ]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
NODE_VERSION: '18'
|
|
||||||
PNPM_VERSION: '8.15.0'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Install dependencies and cache
|
|
||||||
setup:
|
|
||||||
name: Setup Dependencies
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
cache-key: ${{ steps.cache-keys.outputs.node-modules }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
registry-url: 'https://registry.npmjs.org'
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: ${{ env.PNPM_VERSION }}
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Generate cache keys
|
|
||||||
id: cache-keys
|
|
||||||
run: |
|
|
||||||
echo "node-modules=${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: ${{ env.STORE_PATH }}
|
|
||||||
key: ${{ steps.cache-keys.outputs.node-modules }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Cache node_modules
|
|
||||||
uses: actions/cache@v3
|
|
||||||
id: cache-node-modules
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
node_modules
|
|
||||||
packages/*/node_modules
|
|
||||||
key: ${{ steps.cache-keys.outputs.node-modules }}-modules
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-modules-
|
|
||||||
|
|
||||||
# Linting and formatting
|
|
||||||
lint:
|
|
||||||
name: Lint & Format Check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: setup
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: ${{ env.PNPM_VERSION }}
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
node_modules
|
|
||||||
packages/*/node_modules
|
|
||||||
key: ${{ needs.setup.outputs.cache-key }}-modules
|
|
||||||
|
|
||||||
- name: Run ESLint
|
|
||||||
run: pnpm lint
|
|
||||||
|
|
||||||
- name: Check Prettier formatting
|
|
||||||
run: pnpm format:check
|
|
||||||
|
|
||||||
- name: TypeScript type check
|
|
||||||
run: pnpm typecheck
|
|
||||||
|
|
||||||
# Unit tests
|
|
||||||
test:
|
|
||||||
name: Unit Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: setup
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
package: [api, worker, frontend, shared]
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: ${{ env.PNPM_VERSION }}
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
node_modules
|
|
||||||
packages/*/node_modules
|
|
||||||
key: ${{ needs.setup.outputs.cache-key }}-modules
|
|
||||||
|
|
||||||
- name: Run tests for ${{ matrix.package }}
|
|
||||||
run: pnpm --filter @ai-renamer/${{ matrix.package }} test
|
|
||||||
|
|
||||||
- name: Generate coverage report
|
|
||||||
run: pnpm --filter @ai-renamer/${{ matrix.package }} test:coverage
|
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
|
||||||
uses: codecov/codecov-action@v3
|
|
||||||
with:
|
|
||||||
file: ./packages/${{ matrix.package }}/coverage/lcov.info
|
|
||||||
flags: ${{ matrix.package }}
|
|
||||||
name: ${{ matrix.package }}-coverage
|
|
||||||
fail_ci_if_error: false
|
|
||||||
|
|
||||||
# Integration tests
|
|
||||||
integration-test:
|
|
||||||
name: Integration Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: setup
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
env:
|
|
||||||
POSTGRES_PASSWORD: test_password
|
|
||||||
POSTGRES_DB: ai_image_renamer_test
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
options: >-
|
|
||||||
--health-cmd "redis-cli ping"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
|
|
||||||
minio:
|
|
||||||
image: minio/minio:latest
|
|
||||||
env:
|
|
||||||
MINIO_ROOT_USER: test_user
|
|
||||||
MINIO_ROOT_PASSWORD: test_password
|
|
||||||
options: >-
|
|
||||||
--health-cmd "curl -f http://localhost:9000/minio/health/live"
|
|
||||||
--health-interval 30s
|
|
||||||
--health-timeout 20s
|
|
||||||
--health-retries 3
|
|
||||||
ports:
|
|
||||||
- 9000:9000
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: ${{ env.PNPM_VERSION }}
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
node_modules
|
|
||||||
packages/*/node_modules
|
|
||||||
key: ${{ needs.setup.outputs.cache-key }}-modules
|
|
||||||
|
|
||||||
- name: Setup test environment
|
|
||||||
run: |
|
|
||||||
cp .env.example .env.test
|
|
||||||
echo "DATABASE_URL=postgresql://postgres:test_password@localhost:5432/ai_image_renamer_test" >> .env.test
|
|
||||||
echo "REDIS_URL=redis://localhost:6379" >> .env.test
|
|
||||||
echo "MINIO_ENDPOINT=localhost:9000" >> .env.test
|
|
||||||
echo "MINIO_ACCESS_KEY=test_user" >> .env.test
|
|
||||||
echo "MINIO_SECRET_KEY=test_password" >> .env.test
|
|
||||||
|
|
||||||
- name: Run database migrations
|
|
||||||
run: pnpm --filter @ai-renamer/api db:migrate
|
|
||||||
|
|
||||||
- name: Run integration tests
|
|
||||||
run: pnpm test:integration
|
|
||||||
env:
|
|
||||||
NODE_ENV: test
|
|
||||||
|
|
||||||
# Build application
|
|
||||||
build:
|
|
||||||
name: Build Application
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [setup, lint, test]
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: ${{ env.PNPM_VERSION }}
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
node_modules
|
|
||||||
packages/*/node_modules
|
|
||||||
key: ${{ needs.setup.outputs.cache-key }}-modules
|
|
||||||
|
|
||||||
- name: Build all packages
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Upload build artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: build-artifacts
|
|
||||||
path: |
|
|
||||||
packages/*/dist
|
|
||||||
packages/*/build
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
# Docker build and test
|
|
||||||
docker:
|
|
||||||
name: Docker Build & Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [setup, lint, test]
|
|
||||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
target: production
|
|
||||||
tags: ai-bulk-image-renamer:${{ github.sha }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
outputs: type=docker,dest=/tmp/image.tar
|
|
||||||
|
|
||||||
- name: Upload Docker image artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: docker-image
|
|
||||||
path: /tmp/image.tar
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
- name: Test Docker image
|
|
||||||
run: |
|
|
||||||
docker load < /tmp/image.tar
|
|
||||||
docker run --rm --name test-container -d \
|
|
||||||
-e NODE_ENV=test \
|
|
||||||
ai-bulk-image-renamer:${{ github.sha }}
|
|
||||||
sleep 10
|
|
||||||
docker logs test-container
|
|
||||||
docker stop test-container
|
|
||||||
|
|
||||||
# Security scanning
|
|
||||||
security:
|
|
||||||
name: Security Scan
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: setup
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: ${{ env.PNPM_VERSION }}
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
node_modules
|
|
||||||
packages/*/node_modules
|
|
||||||
key: ${{ needs.setup.outputs.cache-key }}-modules
|
|
||||||
|
|
||||||
- name: Run npm audit
|
|
||||||
run: pnpm audit --audit-level moderate
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Run Snyk security scan
|
|
||||||
uses: snyk/actions/node@master
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
|
||||||
with:
|
|
||||||
args: --severity-threshold=medium
|
|
||||||
|
|
||||||
# Dependency updates check
|
|
||||||
dependency-updates:
|
|
||||||
name: Check Dependency Updates
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: ${{ env.PNPM_VERSION }}
|
|
||||||
|
|
||||||
- name: Check for outdated dependencies
|
|
||||||
run: pnpm outdated
|
|
||||||
|
|
||||||
- name: Create dependency update issue
|
|
||||||
if: failure()
|
|
||||||
uses: actions/github-script@v6
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
github.rest.issues.create({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
title: 'Dependency Updates Available',
|
|
||||||
body: 'Automated check found outdated dependencies. Please review and update.',
|
|
||||||
labels: ['dependencies', 'maintenance']
|
|
||||||
})
|
|
||||||
|
|
||||||
# Deployment readiness check
|
|
||||||
deploy-check:
|
|
||||||
name: Deployment Readiness
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build, docker, security, integration-test]
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
steps:
|
|
||||||
- name: Deployment ready
|
|
||||||
run: |
|
|
||||||
echo "✅ All checks passed - ready for deployment"
|
|
||||||
echo "Build artifacts and Docker image are available"
|
|
||||||
echo "Security scans completed"
|
|
||||||
echo "Integration tests passed"
|
|
||||||
366
.gitignore
vendored
366
.gitignore
vendored
|
|
@ -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
|
|
||||||
86
.prettierrc
86
.prettierrc
|
|
@ -1,86 +0,0 @@
|
||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"singleQuote": true,
|
|
||||||
"printWidth": 80,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"quoteProps": "as-needed",
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"bracketSameLine": false,
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"embeddedLanguageFormatting": "auto",
|
|
||||||
"htmlWhitespaceSensitivity": "css",
|
|
||||||
"insertPragma": false,
|
|
||||||
"jsxSingleQuote": true,
|
|
||||||
"proseWrap": "preserve",
|
|
||||||
"requirePragma": false,
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "*.json",
|
|
||||||
"options": {
|
|
||||||
"printWidth": 120,
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": "*.md",
|
|
||||||
"options": {
|
|
||||||
"printWidth": 100,
|
|
||||||
"proseWrap": "always",
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": "*.yml",
|
|
||||||
"options": {
|
|
||||||
"tabWidth": 2,
|
|
||||||
"singleQuote": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": "*.yaml",
|
|
||||||
"options": {
|
|
||||||
"tabWidth": 2,
|
|
||||||
"singleQuote": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": "*.html",
|
|
||||||
"options": {
|
|
||||||
"printWidth": 120,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"htmlWhitespaceSensitivity": "ignore"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": "*.css",
|
|
||||||
"options": {
|
|
||||||
"printWidth": 120,
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": "*.scss",
|
|
||||||
"options": {
|
|
||||||
"printWidth": 120,
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": "*.tsx",
|
|
||||||
"options": {
|
|
||||||
"jsxSingleQuote": true,
|
|
||||||
"bracketSameLine": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": "*.jsx",
|
|
||||||
"options": {
|
|
||||||
"jsxSingleQuote": true,
|
|
||||||
"bracketSameLine": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
125
CLAUDE.md
125
CLAUDE.md
|
|
@ -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.
|
|
||||||
126
Dockerfile
126
Dockerfile
|
|
@ -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"]
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
displayName: 'SEO Image Renamer API',
|
|
||||||
testEnvironment: 'node',
|
|
||||||
rootDir: 'packages/api',
|
|
||||||
testMatch: [
|
|
||||||
'<rootDir>/src/**/*.spec.ts',
|
|
||||||
'<rootDir>/src/**/*.test.ts',
|
|
||||||
'<rootDir>/test/**/*.e2e-spec.ts',
|
|
||||||
],
|
|
||||||
transform: {
|
|
||||||
'^.+\\.(t|j)s$': 'ts-jest',
|
|
||||||
},
|
|
||||||
collectCoverageFrom: [
|
|
||||||
'src/**/*.(t|j)s',
|
|
||||||
'!src/**/*.spec.ts',
|
|
||||||
'!src/**/*.test.ts',
|
|
||||||
'!src/**/*.interface.ts',
|
|
||||||
'!src/**/*.dto.ts',
|
|
||||||
'!src/**/*.entity.ts',
|
|
||||||
'!src/main.ts',
|
|
||||||
],
|
|
||||||
coverageDirectory: '../../coverage',
|
|
||||||
coverageReporters: ['text', 'lcov', 'html'],
|
|
||||||
coverageThreshold: {
|
|
||||||
global: {
|
|
||||||
branches: 80,
|
|
||||||
functions: 80,
|
|
||||||
lines: 80,
|
|
||||||
statements: 80,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
|
|
||||||
moduleNameMapping: {
|
|
||||||
'^@/(.*)$': '<rootDir>/src/$1',
|
|
||||||
},
|
|
||||||
testTimeout: 30000,
|
|
||||||
maxWorkers: 4,
|
|
||||||
verbose: true,
|
|
||||||
detectOpenHandles: true,
|
|
||||||
forceExit: true,
|
|
||||||
};
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: seo-image-renamer
|
|
||||||
labels:
|
|
||||||
app: seo-image-renamer
|
|
||||||
environment: production
|
|
||||||
|
|
@ -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=
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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"}
|
|
||||||
78
package.json
78
package.json
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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])
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
|
|
@ -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<string, boolean>) {
|
|
||||||
try {
|
|
||||||
await this.systemService.updateFeatureFlags(body);
|
|
||||||
return { message: 'Feature flags updated successfully' };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to update feature flags:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Failed to update feature flags',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logs & Monitoring
|
|
||||||
@Get('logs')
|
|
||||||
@ApiOperation({ summary: 'Get system logs' })
|
|
||||||
@ApiResponse({ status: 200, description: 'System logs retrieved successfully' })
|
|
||||||
async getLogs(
|
|
||||||
@Query('level') level?: string,
|
|
||||||
@Query('service') service?: string,
|
|
||||||
@Query('limit') limit: number = 100,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
return await this.systemService.getLogs({ level, service, limit });
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get logs:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Failed to get logs',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('metrics')
|
|
||||||
@ApiOperation({ summary: 'Get system metrics' })
|
|
||||||
@ApiResponse({ status: 200, description: 'System metrics retrieved successfully' })
|
|
||||||
async getMetrics() {
|
|
||||||
try {
|
|
||||||
return await this.systemService.getMetrics();
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get metrics:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Failed to get metrics',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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/*');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
Req,
|
|
||||||
Res,
|
|
||||||
HttpStatus,
|
|
||||||
HttpException,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Request, Response } from 'express';
|
|
||||||
import {
|
|
||||||
ApiTags,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiExcludeEndpoint,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { User } from '@prisma/client';
|
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { GoogleAuthGuard, JwtAuthGuard, Public } from './auth.guard';
|
|
||||||
import {
|
|
||||||
LoginResponseDto,
|
|
||||||
LogoutResponseDto,
|
|
||||||
AuthProfileDto
|
|
||||||
} from './dto/auth.dto';
|
|
||||||
|
|
||||||
export interface AuthenticatedRequest extends Request {
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiTags('Authentication')
|
|
||||||
@Controller('auth')
|
|
||||||
export class AuthController {
|
|
||||||
private readonly logger = new Logger(AuthController.name);
|
|
||||||
|
|
||||||
constructor(private readonly authService: AuthService) {}
|
|
||||||
|
|
||||||
@Get('google')
|
|
||||||
@Public()
|
|
||||||
@UseGuards(GoogleAuthGuard)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Initiate Google OAuth authentication',
|
|
||||||
description: 'Redirects user to Google OAuth consent screen'
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 302,
|
|
||||||
description: 'Redirect to Google OAuth'
|
|
||||||
})
|
|
||||||
@ApiExcludeEndpoint() // Don't show in Swagger UI as it's a redirect
|
|
||||||
async googleAuth() {
|
|
||||||
// Guard handles the redirect to Google
|
|
||||||
// This method exists for the decorator
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('google/callback')
|
|
||||||
@Public()
|
|
||||||
@UseGuards(GoogleAuthGuard)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Google OAuth callback',
|
|
||||||
description: 'Handles the callback from Google OAuth and creates/logs in user'
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Authentication successful',
|
|
||||||
type: LoginResponseDto
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 401,
|
|
||||||
description: 'Authentication failed'
|
|
||||||
})
|
|
||||||
@ApiExcludeEndpoint() // Don't show in Swagger UI as it's a callback
|
|
||||||
async googleCallback(
|
|
||||||
@Req() req: AuthenticatedRequest,
|
|
||||||
@Res() res: Response,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
if (!req.user) {
|
|
||||||
throw new HttpException('Authentication failed', HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate JWT tokens for the authenticated user
|
|
||||||
const tokenData = await this.authService.generateTokens(req.user);
|
|
||||||
|
|
||||||
// Get frontend URL from config
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
|
||||||
|
|
||||||
// Set secure HTTP-only cookie with the JWT token
|
|
||||||
res.cookie('access_token', tokenData.accessToken, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: tokenData.expiresIn * 1000, // Convert to milliseconds
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect to frontend with success indication
|
|
||||||
const redirectUrl = `${frontendUrl}/auth/success?user=${encodeURIComponent(
|
|
||||||
JSON.stringify({
|
|
||||||
id: tokenData.user.id,
|
|
||||||
email: tokenData.user.email,
|
|
||||||
plan: tokenData.user.plan,
|
|
||||||
quotaRemaining: tokenData.user.quotaRemaining,
|
|
||||||
})
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
this.logger.log(`User ${req.user.email} authenticated successfully`);
|
|
||||||
|
|
||||||
return res.redirect(redirectUrl);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('OAuth callback error:', error);
|
|
||||||
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
|
||||||
return res.redirect(`${frontendUrl}/auth/error?message=${encodeURIComponent('Authentication failed')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('logout')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Logout user',
|
|
||||||
description: 'Invalidates the user session and clears authentication cookies'
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Successfully logged out',
|
|
||||||
type: LogoutResponseDto
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 401,
|
|
||||||
description: 'Unauthorized'
|
|
||||||
})
|
|
||||||
async logout(
|
|
||||||
@Req() req: AuthenticatedRequest,
|
|
||||||
@Res() res: Response,
|
|
||||||
): Promise<Response> {
|
|
||||||
try {
|
|
||||||
const result = await this.authService.logout(req.user.id);
|
|
||||||
|
|
||||||
// Clear the authentication cookie
|
|
||||||
res.clearCookie('access_token', {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`User ${req.user.email} logged out successfully`);
|
|
||||||
|
|
||||||
return res.status(HttpStatus.OK).json(result);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Logout error:', error);
|
|
||||||
throw new HttpException('Logout failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('profile')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get current user profile',
|
|
||||||
description: 'Returns the authenticated user\'s profile information'
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'User profile retrieved successfully',
|
|
||||||
type: AuthProfileDto
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 401,
|
|
||||||
description: 'Unauthorized'
|
|
||||||
})
|
|
||||||
async getProfile(@Req() req: AuthenticatedRequest): Promise<AuthProfileDto> {
|
|
||||||
try {
|
|
||||||
const user = await this.authService.getProfile(req.user.id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
plan: user.plan,
|
|
||||||
quotaRemaining: user.quotaRemaining,
|
|
||||||
quotaResetDate: user.quotaResetDate,
|
|
||||||
isActive: user.isActive,
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Get profile error:', error);
|
|
||||||
throw new HttpException('Failed to retrieve profile', HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('status')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Check authentication status',
|
|
||||||
description: 'Verifies if the current JWT token is valid'
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Token is valid',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
authenticated: { type: 'boolean', example: true },
|
|
||||||
user: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
id: { type: 'string' },
|
|
||||||
email: { type: 'string' },
|
|
||||||
plan: { type: 'string' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 401,
|
|
||||||
description: 'Token is invalid or expired'
|
|
||||||
})
|
|
||||||
async checkStatus(@Req() req: AuthenticatedRequest) {
|
|
||||||
return {
|
|
||||||
authenticated: true,
|
|
||||||
user: {
|
|
||||||
id: req.user.id,
|
|
||||||
email: req.user.email,
|
|
||||||
plan: req.user.plan,
|
|
||||||
quotaRemaining: req.user.quotaRemaining,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import {
|
|
||||||
Injectable,
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
UnauthorizedException,
|
|
||||||
SetMetadata,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
|
|
||||||
// Decorator to mark routes as public (skip authentication)
|
|
||||||
export const IS_PUBLIC_KEY = 'isPublic';
|
|
||||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
|
||||||
|
|
||||||
// Decorator to mark routes as optional authentication
|
|
||||||
export const IS_OPTIONAL_AUTH_KEY = 'isOptionalAuth';
|
|
||||||
export const OptionalAuth = () => SetMetadata(IS_OPTIONAL_AUTH_KEY, true);
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
|
|
||||||
constructor(private reflector: Reflector) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
canActivate(
|
|
||||||
context: ExecutionContext,
|
|
||||||
): boolean | Promise<boolean> | Observable<boolean> {
|
|
||||||
// Check if route is marked as public
|
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isPublic) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if route has optional authentication
|
|
||||||
const isOptionalAuth = this.reflector.getAllAndOverride<boolean>(
|
|
||||||
IS_OPTIONAL_AUTH_KEY,
|
|
||||||
[context.getHandler(), context.getClass()],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isOptionalAuth) {
|
|
||||||
// Try to authenticate but don't fail if no token
|
|
||||||
try {
|
|
||||||
return super.canActivate(context);
|
|
||||||
} catch {
|
|
||||||
return true; // Allow request to proceed without authentication
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default behavior: require authentication
|
|
||||||
return super.canActivate(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleRequest(err: any, user: any, info: any, context: ExecutionContext) {
|
|
||||||
// Check if route has optional authentication
|
|
||||||
const isOptionalAuth = this.reflector.getAllAndOverride<boolean>(
|
|
||||||
IS_OPTIONAL_AUTH_KEY,
|
|
||||||
[context.getHandler(), context.getClass()],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (err || !user) {
|
|
||||||
if (isOptionalAuth) {
|
|
||||||
return null; // No user, but that's okay for optional auth
|
|
||||||
}
|
|
||||||
throw err || new UnauthorizedException('Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GoogleAuthGuard extends AuthGuard('google') {
|
|
||||||
constructor() {
|
|
||||||
super({
|
|
||||||
accessType: 'offline',
|
|
||||||
prompt: 'consent',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
|
||||||
import { PassportModule } from '@nestjs/passport';
|
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
import { AuthController } from './auth.controller';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { GoogleStrategy } from './google.strategy';
|
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
|
||||||
import { DatabaseModule } from '../database/database.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
DatabaseModule,
|
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
|
||||||
JwtModule.registerAsync({
|
|
||||||
imports: [ConfigModule],
|
|
||||||
useFactory: async (configService: ConfigService) => ({
|
|
||||||
secret: configService.get<string>('JWT_SECRET'),
|
|
||||||
signOptions: {
|
|
||||||
expiresIn: configService.get<string>('JWT_EXPIRES_IN', '7d'),
|
|
||||||
issuer: 'seo-image-renamer',
|
|
||||||
audience: 'seo-image-renamer-users',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
inject: [ConfigService],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
controllers: [AuthController],
|
|
||||||
providers: [AuthService, GoogleStrategy, JwtStrategy],
|
|
||||||
exports: [AuthService, JwtModule],
|
|
||||||
})
|
|
||||||
export class AuthModule {}
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { UserRepository } from '../database/repositories/user.repository';
|
|
||||||
import { Plan } from '@prisma/client';
|
|
||||||
|
|
||||||
describe('AuthService', () => {
|
|
||||||
let service: AuthService;
|
|
||||||
let userRepository: jest.Mocked<UserRepository>;
|
|
||||||
let jwtService: jest.Mocked<JwtService>;
|
|
||||||
let configService: jest.Mocked<ConfigService>;
|
|
||||||
|
|
||||||
const mockUser = {
|
|
||||||
id: 'user-123',
|
|
||||||
email: 'test@example.com',
|
|
||||||
plan: Plan.BASIC,
|
|
||||||
quotaRemaining: 50,
|
|
||||||
quotaResetDate: new Date(),
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
AuthService,
|
|
||||||
{
|
|
||||||
provide: UserRepository,
|
|
||||||
useValue: {
|
|
||||||
findByEmail: jest.fn(),
|
|
||||||
findByGoogleUid: jest.fn(),
|
|
||||||
createWithOAuth: jest.fn(),
|
|
||||||
linkGoogleAccount: jest.fn(),
|
|
||||||
updateLastLogin: jest.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: JwtService,
|
|
||||||
useValue: {
|
|
||||||
sign: jest.fn(),
|
|
||||||
verify: jest.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: ConfigService,
|
|
||||||
useValue: {
|
|
||||||
get: jest.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<AuthService>(AuthService);
|
|
||||||
userRepository = module.get(UserRepository);
|
|
||||||
jwtService = module.get(JwtService);
|
|
||||||
configService = module.get(ConfigService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateGoogleUser', () => {
|
|
||||||
const googleProfile = {
|
|
||||||
id: 'google-123',
|
|
||||||
emails: [{ value: 'test@example.com', verified: true }],
|
|
||||||
displayName: 'Test User',
|
|
||||||
photos: [{ value: 'https://example.com/photo.jpg' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should return existing user if found by Google UID', async () => {
|
|
||||||
userRepository.findByGoogleUid.mockResolvedValue(mockUser);
|
|
||||||
|
|
||||||
const result = await service.validateGoogleUser(googleProfile);
|
|
||||||
|
|
||||||
expect(result).toEqual(mockUser);
|
|
||||||
expect(userRepository.findByGoogleUid).toHaveBeenCalledWith('google-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return existing user if found by email and link Google account', async () => {
|
|
||||||
userRepository.findByGoogleUid.mockResolvedValue(null);
|
|
||||||
userRepository.findByEmail.mockResolvedValue(mockUser);
|
|
||||||
userRepository.linkGoogleAccount.mockResolvedValue(mockUser);
|
|
||||||
|
|
||||||
const result = await service.validateGoogleUser(googleProfile);
|
|
||||||
|
|
||||||
expect(result).toEqual(mockUser);
|
|
||||||
expect(userRepository.linkGoogleAccount).toHaveBeenCalledWith('user-123', 'google-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create new user if not found', async () => {
|
|
||||||
userRepository.findByGoogleUid.mockResolvedValue(null);
|
|
||||||
userRepository.findByEmail.mockResolvedValue(null);
|
|
||||||
userRepository.createWithOAuth.mockResolvedValue(mockUser);
|
|
||||||
|
|
||||||
const result = await service.validateGoogleUser(googleProfile);
|
|
||||||
|
|
||||||
expect(result).toEqual(mockUser);
|
|
||||||
expect(userRepository.createWithOAuth).toHaveBeenCalledWith({
|
|
||||||
googleUid: 'google-123',
|
|
||||||
email: 'test@example.com',
|
|
||||||
emailHash: expect.any(String),
|
|
||||||
plan: Plan.BASIC,
|
|
||||||
quotaRemaining: 50,
|
|
||||||
quotaResetDate: expect.any(Date),
|
|
||||||
isActive: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error if no email provided', async () => {
|
|
||||||
const profileWithoutEmail = {
|
|
||||||
...googleProfile,
|
|
||||||
emails: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(service.validateGoogleUser(profileWithoutEmail)).rejects.toThrow(
|
|
||||||
'No email provided by Google'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateJwtToken', () => {
|
|
||||||
it('should generate JWT token with user payload', async () => {
|
|
||||||
const token = 'jwt-token-123';
|
|
||||||
jwtService.sign.mockReturnValue(token);
|
|
||||||
|
|
||||||
const result = await service.generateJwtToken(mockUser);
|
|
||||||
|
|
||||||
expect(result).toBe(token);
|
|
||||||
expect(jwtService.sign).toHaveBeenCalledWith({
|
|
||||||
sub: mockUser.id,
|
|
||||||
email: mockUser.email,
|
|
||||||
plan: mockUser.plan,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verifyJwtToken', () => {
|
|
||||||
it('should verify and return JWT payload', async () => {
|
|
||||||
const payload = { sub: 'user-123', email: 'test@example.com' };
|
|
||||||
jwtService.verify.mockReturnValue(payload);
|
|
||||||
|
|
||||||
const result = await service.verifyJwtToken('jwt-token');
|
|
||||||
|
|
||||||
expect(result).toEqual(payload);
|
|
||||||
expect(jwtService.verify).toHaveBeenCalledWith('jwt-token');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for invalid token', async () => {
|
|
||||||
jwtService.verify.mockImplementation(() => {
|
|
||||||
throw new Error('Invalid token');
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(service.verifyJwtToken('invalid-token')).rejects.toThrow(
|
|
||||||
'Invalid token'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateUser', () => {
|
|
||||||
it('should return user if found and active', async () => {
|
|
||||||
userRepository.findById.mockResolvedValue(mockUser);
|
|
||||||
|
|
||||||
const result = await service.validateUser('user-123');
|
|
||||||
|
|
||||||
expect(result).toEqual(mockUser);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if user not found', async () => {
|
|
||||||
userRepository.findById.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await service.validateUser('user-123');
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if user is inactive', async () => {
|
|
||||||
const inactiveUser = { ...mockUser, isActive: false };
|
|
||||||
userRepository.findById.mockResolvedValue(inactiveUser);
|
|
||||||
|
|
||||||
const result = await service.validateUser('user-123');
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hashEmail', () => {
|
|
||||||
it('should hash email consistently', () => {
|
|
||||||
const email = 'test@example.com';
|
|
||||||
const hash1 = service.hashEmail(email);
|
|
||||||
const hash2 = service.hashEmail(email);
|
|
||||||
|
|
||||||
expect(hash1).toBe(hash2);
|
|
||||||
expect(hash1).toHaveLength(64); // SHA-256 produces 64 character hex string
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should produce different hashes for different emails', () => {
|
|
||||||
const hash1 = service.hashEmail('test1@example.com');
|
|
||||||
const hash2 = service.hashEmail('test2@example.com');
|
|
||||||
|
|
||||||
expect(hash1).not.toBe(hash2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
import {
|
|
||||||
Injectable,
|
|
||||||
UnauthorizedException,
|
|
||||||
ConflictException,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { User, Plan } from '@prisma/client';
|
|
||||||
import { createHash } from 'crypto';
|
|
||||||
|
|
||||||
import { UserRepository } from '../database/repositories/user.repository';
|
|
||||||
import { LoginResponseDto, AuthUserDto } from './dto/auth.dto';
|
|
||||||
import { calculateQuotaResetDate, getQuotaLimitForPlan } from '../users/users.entity';
|
|
||||||
|
|
||||||
export interface GoogleUserData {
|
|
||||||
googleUid: string;
|
|
||||||
email: string;
|
|
||||||
displayName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JwtPayload {
|
|
||||||
sub: string; // User ID
|
|
||||||
email: string;
|
|
||||||
iat?: number;
|
|
||||||
exp?: number;
|
|
||||||
iss?: string;
|
|
||||||
aud?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthService {
|
|
||||||
constructor(
|
|
||||||
private readonly userRepository: UserRepository,
|
|
||||||
private readonly jwtService: JwtService,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and find/create user from Google OAuth data
|
|
||||||
*/
|
|
||||||
async validateGoogleUser(googleUserData: GoogleUserData): Promise<User> {
|
|
||||||
const { googleUid, email, displayName } = googleUserData;
|
|
||||||
|
|
||||||
// First, try to find user by Google UID
|
|
||||||
let user = await this.userRepository.findByGoogleUid(googleUid);
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
// User exists, update last login and return
|
|
||||||
return await this.userRepository.updateLastLogin(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user exists with this email but no Google UID (existing account)
|
|
||||||
const existingUser = await this.userRepository.findByEmail(email);
|
|
||||||
if (existingUser && !existingUser.googleUid) {
|
|
||||||
// Link Google account to existing user
|
|
||||||
return await this.userRepository.linkGoogleAccount(existingUser.id, googleUid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingUser && existingUser.googleUid && existingUser.googleUid !== googleUid) {
|
|
||||||
throw new ConflictException('Email already associated with different Google account');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new user account
|
|
||||||
return await this.createUserFromGoogle(googleUserData);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create new user from Google OAuth data
|
|
||||||
*/
|
|
||||||
private async createUserFromGoogle(googleUserData: GoogleUserData): Promise<User> {
|
|
||||||
const { googleUid, email, displayName } = googleUserData;
|
|
||||||
|
|
||||||
// Hash the email for privacy (SHA-256)
|
|
||||||
const emailHash = this.hashEmail(email);
|
|
||||||
|
|
||||||
// Create user with Basic plan and 50 quota as per requirements
|
|
||||||
const userData = {
|
|
||||||
googleUid,
|
|
||||||
email,
|
|
||||||
emailHash,
|
|
||||||
plan: Plan.BASIC,
|
|
||||||
quotaRemaining: getQuotaLimitForPlan(Plan.BASIC),
|
|
||||||
quotaResetDate: calculateQuotaResetDate(),
|
|
||||||
isActive: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return await this.userRepository.createWithOAuth(userData);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate user by ID (for JWT strategy)
|
|
||||||
*/
|
|
||||||
async validateUserById(userId: string): Promise<User | null> {
|
|
||||||
return await this.userRepository.findById(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate JWT token for user
|
|
||||||
*/
|
|
||||||
async generateTokens(user: User): Promise<LoginResponseDto> {
|
|
||||||
const payload: JwtPayload = {
|
|
||||||
sub: user.id,
|
|
||||||
email: user.email,
|
|
||||||
};
|
|
||||||
|
|
||||||
const accessToken = await this.jwtService.signAsync(payload);
|
|
||||||
const expiresIn = this.getTokenExpirationSeconds();
|
|
||||||
|
|
||||||
const authUser: AuthUserDto = {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
displayName: user.email.split('@')[0], // Use email prefix as display name
|
|
||||||
plan: user.plan,
|
|
||||||
quotaRemaining: user.quotaRemaining,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken,
|
|
||||||
tokenType: 'Bearer',
|
|
||||||
expiresIn,
|
|
||||||
user: authUser,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user profile information
|
|
||||||
*/
|
|
||||||
async getProfile(userId: string): Promise<User> {
|
|
||||||
const user = await this.userRepository.findById(userId);
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException('User not found');
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hash email using SHA-256 for privacy
|
|
||||||
*/
|
|
||||||
private hashEmail(email: string): string {
|
|
||||||
return createHash('sha256').update(email.toLowerCase().trim()).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get token expiration time in seconds
|
|
||||||
*/
|
|
||||||
private getTokenExpirationSeconds(): number {
|
|
||||||
const expiresIn = this.configService.get<string>('JWT_EXPIRES_IN', '7d');
|
|
||||||
|
|
||||||
// Convert duration string to seconds
|
|
||||||
if (expiresIn.endsWith('d')) {
|
|
||||||
return parseInt(expiresIn.replace('d', '')) * 24 * 60 * 60;
|
|
||||||
} else if (expiresIn.endsWith('h')) {
|
|
||||||
return parseInt(expiresIn.replace('h', '')) * 60 * 60;
|
|
||||||
} else if (expiresIn.endsWith('m')) {
|
|
||||||
return parseInt(expiresIn.replace('m', '')) * 60;
|
|
||||||
} else if (expiresIn.endsWith('s')) {
|
|
||||||
return parseInt(expiresIn.replace('s', ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to seconds if no unit specified
|
|
||||||
return parseInt(expiresIn) || 604800; // 7 days default
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate JWT token and return payload
|
|
||||||
*/
|
|
||||||
async validateToken(token: string): Promise<JwtPayload | null> {
|
|
||||||
try {
|
|
||||||
return await this.jwtService.verifyAsync(token);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate user session (for logout)
|
|
||||||
* Note: With stateless JWT, we rely on token expiration
|
|
||||||
* In production, consider maintaining a blacklist
|
|
||||||
*/
|
|
||||||
async logout(userId: string): Promise<{ message: string }> {
|
|
||||||
// Update user's last activity
|
|
||||||
await this.userRepository.updateLastActivity(userId);
|
|
||||||
|
|
||||||
return { message: 'Successfully logged out' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
|
|
||||||
export interface GoogleProfile {
|
|
||||||
id: string;
|
|
||||||
displayName: string;
|
|
||||||
name: {
|
|
||||||
familyName: string;
|
|
||||||
givenName: string;
|
|
||||||
};
|
|
||||||
emails: Array<{
|
|
||||||
value: string;
|
|
||||||
verified: boolean;
|
|
||||||
}>;
|
|
||||||
photos: Array<{
|
|
||||||
value: string;
|
|
||||||
}>;
|
|
||||||
provider: string;
|
|
||||||
_raw: string;
|
|
||||||
_json: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|
||||||
constructor(
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
private readonly authService: AuthService,
|
|
||||||
) {
|
|
||||||
super({
|
|
||||||
clientID: configService.get<string>('GOOGLE_CLIENT_ID'),
|
|
||||||
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET'),
|
|
||||||
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL'),
|
|
||||||
scope: ['email', 'profile'], // Only request email and profile scopes as per requirements
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(
|
|
||||||
accessToken: string,
|
|
||||||
refreshToken: string,
|
|
||||||
profile: GoogleProfile,
|
|
||||||
done: VerifyCallback,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Extract user information from Google profile
|
|
||||||
const { id, displayName, emails } = profile;
|
|
||||||
|
|
||||||
if (!emails || emails.length === 0) {
|
|
||||||
return done(new Error('No email found in Google profile'), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = emails[0].value;
|
|
||||||
|
|
||||||
// Find or create user through auth service
|
|
||||||
const user = await this.authService.validateGoogleUser({
|
|
||||||
googleUid: id,
|
|
||||||
email,
|
|
||||||
displayName,
|
|
||||||
});
|
|
||||||
|
|
||||||
return done(null, user);
|
|
||||||
} catch (error) {
|
|
||||||
return done(error, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Strategy, ExtractJwt } from 'passport-jwt';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
|
|
||||||
export interface JwtPayload {
|
|
||||||
sub: string; // User ID
|
|
||||||
email: string;
|
|
||||||
iat: number; // Issued at
|
|
||||||
exp: number; // Expires at
|
|
||||||
iss: string; // Issuer
|
|
||||||
aud: string; // Audience
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|
||||||
constructor(
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
private readonly authService: AuthService,
|
|
||||||
) {
|
|
||||||
super({
|
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
||||||
ignoreExpiration: false,
|
|
||||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
|
||||||
issuer: 'seo-image-renamer',
|
|
||||||
audience: 'seo-image-renamer-users',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(payload: JwtPayload) {
|
|
||||||
try {
|
|
||||||
// Verify the user still exists and is active
|
|
||||||
const user = await this.authService.validateUserById(payload.sub);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isActive) {
|
|
||||||
throw new UnauthorizedException('User account is inactive');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return user object that will be attached to request
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
plan: user.plan,
|
|
||||||
quotaRemaining: user.quotaRemaining,
|
|
||||||
isActive: user.isActive,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new UnauthorizedException('Invalid token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
import {
|
|
||||||
IsString,
|
|
||||||
IsEnum,
|
|
||||||
IsInt,
|
|
||||||
IsOptional,
|
|
||||||
IsUUID,
|
|
||||||
IsObject,
|
|
||||||
Min,
|
|
||||||
IsDate
|
|
||||||
} from 'class-validator';
|
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { BatchStatus } from '@prisma/client';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
|
|
||||||
export class CreateBatchDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'ID of the user creating the batch',
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
|
||||||
})
|
|
||||||
@IsUUID()
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Total number of images in this batch',
|
|
||||||
example: 10,
|
|
||||||
minimum: 0
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
totalImages?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Additional metadata for the batch processing',
|
|
||||||
example: {
|
|
||||||
aiModel: 'gpt-4-vision',
|
|
||||||
processingOptions: { includeColors: true, includeTags: true }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateBatchDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Batch processing status',
|
|
||||||
enum: BatchStatus
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(BatchStatus)
|
|
||||||
status?: BatchStatus;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Total number of images in this batch',
|
|
||||||
minimum: 0
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
totalImages?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Number of processed images',
|
|
||||||
minimum: 0
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
processedImages?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Number of failed images',
|
|
||||||
minimum: 0
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
failedImages?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Additional metadata for the batch processing'
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BatchResponseDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Unique batch identifier',
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
|
||||||
})
|
|
||||||
@IsUUID()
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'ID of the user who owns this batch',
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
|
||||||
})
|
|
||||||
@IsUUID()
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Current batch processing status',
|
|
||||||
enum: BatchStatus
|
|
||||||
})
|
|
||||||
@IsEnum(BatchStatus)
|
|
||||||
status: BatchStatus;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Total number of images in this batch',
|
|
||||||
example: 10
|
|
||||||
})
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
totalImages: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Number of processed images',
|
|
||||||
example: 8
|
|
||||||
})
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
processedImages: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Number of failed images',
|
|
||||||
example: 1
|
|
||||||
})
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
failedImages: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Additional metadata for the batch processing'
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Batch creation timestamp'
|
|
||||||
})
|
|
||||||
@IsDate()
|
|
||||||
createdAt: Date;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Batch last update timestamp'
|
|
||||||
})
|
|
||||||
@IsDate()
|
|
||||||
updatedAt: Date;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Batch completion timestamp'
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsDate()
|
|
||||||
completedAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BatchStatsDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Processing progress percentage',
|
|
||||||
example: 80
|
|
||||||
})
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
progressPercentage: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Number of pending images',
|
|
||||||
example: 1
|
|
||||||
})
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
pendingImages: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Average processing time per image in seconds',
|
|
||||||
example: 5.2
|
|
||||||
})
|
|
||||||
@Type(() => Number)
|
|
||||||
averageProcessingTime: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Estimated time remaining in seconds',
|
|
||||||
example: 30
|
|
||||||
})
|
|
||||||
@Type(() => Number)
|
|
||||||
estimatedTimeRemaining: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BatchSummaryDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Batch details'
|
|
||||||
})
|
|
||||||
batch: BatchResponseDto;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Processing statistics'
|
|
||||||
})
|
|
||||||
stats: BatchStatsDto;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Recent images from this batch (limited to 5)'
|
|
||||||
})
|
|
||||||
recentImages: Array<{
|
|
||||||
id: string;
|
|
||||||
originalName: string;
|
|
||||||
proposedName?: string;
|
|
||||||
status: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to calculate progress percentage
|
|
||||||
export function calculateProgressPercentage(processedImages: number, totalImages: number): number {
|
|
||||||
if (totalImages === 0) return 0;
|
|
||||||
return Math.round((processedImages / totalImages) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to determine if batch is complete
|
|
||||||
export function isBatchComplete(batch: { status: BatchStatus; processedImages: number; failedImages: number; totalImages: number }): boolean {
|
|
||||||
return batch.status === BatchStatus.DONE ||
|
|
||||||
batch.status === BatchStatus.ERROR ||
|
|
||||||
(batch.processedImages + batch.failedImages) >= batch.totalImages;
|
|
||||||
}
|
|
||||||
|
|
@ -1,275 +0,0 @@
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Body,
|
|
||||||
UploadedFiles,
|
|
||||||
UseInterceptors,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
HttpStatus,
|
|
||||||
BadRequestException,
|
|
||||||
PayloadTooLargeException,
|
|
||||||
ForbiddenException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiConsumes, ApiBearerAuth } from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../auth/auth.guard';
|
|
||||||
import { BatchesService } from './batches.service';
|
|
||||||
import { CreateBatchDto, BatchUploadResponseDto } from './dto/create-batch.dto';
|
|
||||||
import { BatchStatusResponseDto, BatchListResponseDto } from './dto/batch-status.dto';
|
|
||||||
|
|
||||||
@ApiTags('batches')
|
|
||||||
@Controller('api/batch')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
export class BatchesController {
|
|
||||||
constructor(private readonly batchesService: BatchesService) {}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
@UseInterceptors(FilesInterceptor('files', 1000)) // Max 1000 files per batch
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Upload batch of images for processing',
|
|
||||||
description: 'Uploads multiple images and starts batch processing with AI analysis and SEO filename generation'
|
|
||||||
})
|
|
||||||
@ApiConsumes('multipart/form-data')
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Batch created successfully',
|
|
||||||
type: BatchUploadResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.BAD_REQUEST,
|
|
||||||
description: 'Invalid files or missing data',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.PAYLOAD_TOO_LARGE,
|
|
||||||
description: 'File size or count exceeds limits',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.FORBIDDEN,
|
|
||||||
description: 'Insufficient quota remaining',
|
|
||||||
})
|
|
||||||
async uploadBatch(
|
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
|
||||||
@Body() createBatchDto: CreateBatchDto,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<BatchUploadResponseDto> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate files are provided
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
throw new BadRequestException('No files provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file count limits
|
|
||||||
if (files.length > 1000) {
|
|
||||||
throw new PayloadTooLargeException('Maximum 1000 files per batch');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the batch upload
|
|
||||||
const result = await this.batchesService.createBatch(userId, files, createBatchDto);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof BadRequestException ||
|
|
||||||
error instanceof PayloadTooLargeException ||
|
|
||||||
error instanceof ForbiddenException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to process batch upload');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':batchId/status')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get batch processing status',
|
|
||||||
description: 'Returns current status and progress of batch processing'
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Batch status retrieved successfully',
|
|
||||||
type: BatchStatusResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NOT_FOUND,
|
|
||||||
description: 'Batch not found',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.FORBIDDEN,
|
|
||||||
description: 'Not authorized to access this batch',
|
|
||||||
})
|
|
||||||
async getBatchStatus(
|
|
||||||
@Param('batchId') batchId: string,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<BatchStatusResponseDto> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await this.batchesService.getBatchStatus(batchId, userId);
|
|
||||||
return status;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to get batch status');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'List user batches',
|
|
||||||
description: 'Returns list of all batches for the authenticated user'
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Batches retrieved successfully',
|
|
||||||
type: [BatchListResponseDto],
|
|
||||||
})
|
|
||||||
async getUserBatches(
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<BatchListResponseDto[]> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const batches = await this.batchesService.getUserBatches(userId);
|
|
||||||
return batches;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
throw new BadRequestException('Failed to get user batches');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':batchId/cancel')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Cancel batch processing',
|
|
||||||
description: 'Cancels ongoing batch processing'
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Batch cancelled successfully',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NOT_FOUND,
|
|
||||||
description: 'Batch not found',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.FORBIDDEN,
|
|
||||||
description: 'Not authorized to cancel this batch',
|
|
||||||
})
|
|
||||||
async cancelBatch(
|
|
||||||
@Param('batchId') batchId: string,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.batchesService.cancelBatch(batchId, userId);
|
|
||||||
|
|
||||||
return { message: 'Batch cancelled successfully' };
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to cancel batch');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':batchId/retry')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Retry failed batch processing',
|
|
||||||
description: 'Retries processing for failed images in a batch'
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Batch retry started successfully',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NOT_FOUND,
|
|
||||||
description: 'Batch not found',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.BAD_REQUEST,
|
|
||||||
description: 'Batch is not in a retryable state',
|
|
||||||
})
|
|
||||||
async retryBatch(
|
|
||||||
@Param('batchId') batchId: string,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<{ message: string; retry_count: number }> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryCount = await this.batchesService.retryBatch(batchId, userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: 'Batch retry started successfully',
|
|
||||||
retry_count: retryCount
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to retry batch');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':batchId/download')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Download processed batch as ZIP',
|
|
||||||
description: 'Returns a ZIP file containing all processed images with new filenames'
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'ZIP file download started',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NOT_FOUND,
|
|
||||||
description: 'Batch not found',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.BAD_REQUEST,
|
|
||||||
description: 'Batch processing not completed',
|
|
||||||
})
|
|
||||||
async downloadBatch(
|
|
||||||
@Param('batchId') batchId: string,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<{ download_url: string; expires_at: string }> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadInfo = await this.batchesService.generateBatchDownload(batchId, userId);
|
|
||||||
|
|
||||||
return downloadInfo;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to generate batch download');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -1,515 +0,0 @@
|
||||||
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
|
||||||
import { BatchStatus, ImageStatus, Plan } from '@prisma/client';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { PrismaService } from '../database/prisma.service';
|
|
||||||
import { UploadService } from '../upload/upload.service';
|
|
||||||
import { QueueService } from '../queue/queue.service';
|
|
||||||
import { ProgressGateway } from '../websocket/progress.gateway';
|
|
||||||
import { CreateBatchDto, BatchUploadResponseDto } from './dto/create-batch.dto';
|
|
||||||
import { BatchStatusResponseDto, BatchListResponseDto } from './dto/batch-status.dto';
|
|
||||||
import { calculateProgressPercentage } from '../batches/batch.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BatchesService {
|
|
||||||
private readonly logger = new Logger(BatchesService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly uploadService: UploadService,
|
|
||||||
private readonly queueService: QueueService,
|
|
||||||
private readonly progressGateway: ProgressGateway,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new batch and process uploaded files
|
|
||||||
*/
|
|
||||||
async createBatch(
|
|
||||||
userId: string,
|
|
||||||
files: Express.Multer.File[],
|
|
||||||
createBatchDto: CreateBatchDto
|
|
||||||
): Promise<BatchUploadResponseDto> {
|
|
||||||
try {
|
|
||||||
this.logger.log(`Creating batch for user: ${userId} with ${files.length} files`);
|
|
||||||
|
|
||||||
// Get user info and check quota
|
|
||||||
const user = await this.prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { plan: true, quotaRemaining: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new BadRequestException('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check quota
|
|
||||||
const quotaCheck = this.uploadService.checkUploadQuota(
|
|
||||||
files.length,
|
|
||||||
user.plan,
|
|
||||||
user.quotaRemaining
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!quotaCheck.allowed) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
`Insufficient quota. Requested: ${files.length}, Remaining: ${user.quotaRemaining}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create batch record
|
|
||||||
const batchId = uuidv4();
|
|
||||||
const batch = await this.prisma.batch.create({
|
|
||||||
data: {
|
|
||||||
id: batchId,
|
|
||||||
userId,
|
|
||||||
status: BatchStatus.PROCESSING,
|
|
||||||
totalImages: files.length,
|
|
||||||
processedImages: 0,
|
|
||||||
failedImages: 0,
|
|
||||||
metadata: {
|
|
||||||
keywords: createBatchDto.keywords || [],
|
|
||||||
uploadedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process files
|
|
||||||
let acceptedCount = 0;
|
|
||||||
let skippedCount = 0;
|
|
||||||
const imageIds: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const processedFiles = await this.uploadService.processMultipleFiles(
|
|
||||||
files,
|
|
||||||
batchId,
|
|
||||||
createBatchDto.keywords
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create image records in database
|
|
||||||
for (const processedFile of processedFiles) {
|
|
||||||
try {
|
|
||||||
const imageId = uuidv4();
|
|
||||||
|
|
||||||
await this.prisma.image.create({
|
|
||||||
data: {
|
|
||||||
id: imageId,
|
|
||||||
batchId,
|
|
||||||
originalName: processedFile.originalName,
|
|
||||||
status: ImageStatus.PENDING,
|
|
||||||
fileSize: processedFile.uploadResult.size,
|
|
||||||
mimeType: processedFile.mimeType,
|
|
||||||
dimensions: {
|
|
||||||
width: processedFile.metadata.width,
|
|
||||||
height: processedFile.metadata.height,
|
|
||||||
format: processedFile.metadata.format,
|
|
||||||
},
|
|
||||||
s3Key: processedFile.uploadResult.key,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
imageIds.push(imageId);
|
|
||||||
acceptedCount++;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to create image record: ${processedFile.originalName}`, error.stack);
|
|
||||||
skippedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
skippedCount += files.length - processedFiles.length;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to process files for batch: ${batchId}`, error.stack);
|
|
||||||
skippedCount = files.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update batch with actual counts
|
|
||||||
await this.prisma.batch.update({
|
|
||||||
where: { id: batchId },
|
|
||||||
data: {
|
|
||||||
totalImages: acceptedCount,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update user quota
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: {
|
|
||||||
quotaRemaining: user.quotaRemaining - acceptedCount,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Queue batch processing if we have accepted files
|
|
||||||
if (acceptedCount > 0) {
|
|
||||||
await this.queueService.addBatchProcessingJob({
|
|
||||||
batchId,
|
|
||||||
userId,
|
|
||||||
imageIds,
|
|
||||||
keywords: createBatchDto.keywords,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimate processing time (2-5 seconds per image)
|
|
||||||
const estimatedTime = acceptedCount * (3 + Math.random() * 2);
|
|
||||||
|
|
||||||
this.logger.log(`Batch created: ${batchId} - ${acceptedCount} accepted, ${skippedCount} skipped`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
batch_id: batchId,
|
|
||||||
accepted_count: acceptedCount,
|
|
||||||
skipped_count: skippedCount,
|
|
||||||
status: 'PROCESSING',
|
|
||||||
estimated_time: Math.round(estimatedTime),
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to create batch for user: ${userId}`, error.stack);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get batch status and progress
|
|
||||||
*/
|
|
||||||
async getBatchStatus(batchId: string, userId: string): Promise<BatchStatusResponseDto> {
|
|
||||||
try {
|
|
||||||
const batch = await this.prisma.batch.findFirst({
|
|
||||||
where: {
|
|
||||||
id: batchId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
images: {
|
|
||||||
select: {
|
|
||||||
status: true,
|
|
||||||
originalName: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!batch) {
|
|
||||||
throw new NotFoundException('Batch not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate progress
|
|
||||||
const progress = calculateProgressPercentage(batch.processedImages, batch.totalImages);
|
|
||||||
|
|
||||||
// Find currently processing image
|
|
||||||
const processingImage = batch.images.find(img => img.status === ImageStatus.PROCESSING);
|
|
||||||
|
|
||||||
// Estimate remaining time based on average processing time
|
|
||||||
const remainingImages = batch.totalImages - batch.processedImages;
|
|
||||||
const estimatedRemaining = remainingImages * 3; // 3 seconds per image average
|
|
||||||
|
|
||||||
// Map status to API response format
|
|
||||||
let state: 'PROCESSING' | 'DONE' | 'ERROR';
|
|
||||||
switch (batch.status) {
|
|
||||||
case BatchStatus.PROCESSING:
|
|
||||||
state = 'PROCESSING';
|
|
||||||
break;
|
|
||||||
case BatchStatus.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<BatchListResponseDto[]> {
|
|
||||||
try {
|
|
||||||
const batches = await this.prisma.batch.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 50, // Limit to last 50 batches
|
|
||||||
});
|
|
||||||
|
|
||||||
return batches.map(batch => ({
|
|
||||||
id: batch.id,
|
|
||||||
state: batch.status === BatchStatus.PROCESSING ? 'PROCESSING' :
|
|
||||||
batch.status === BatchStatus.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<void> {
|
|
||||||
try {
|
|
||||||
const batch = await this.prisma.batch.findFirst({
|
|
||||||
where: {
|
|
||||||
id: batchId,
|
|
||||||
userId,
|
|
||||||
status: BatchStatus.PROCESSING,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!batch) {
|
|
||||||
throw new NotFoundException('Batch not found or not in processing state');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel queue jobs
|
|
||||||
await this.queueService.cancelJob(`batch-${batchId}`, 'batch-processing');
|
|
||||||
|
|
||||||
// Update batch status
|
|
||||||
await this.prisma.batch.update({
|
|
||||||
where: { id: batchId },
|
|
||||||
data: {
|
|
||||||
status: BatchStatus.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<number> {
|
|
||||||
try {
|
|
||||||
const batch = await this.prisma.batch.findFirst({
|
|
||||||
where: {
|
|
||||||
id: batchId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
images: {
|
|
||||||
where: { status: ImageStatus.FAILED },
|
|
||||||
select: { id: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!batch) {
|
|
||||||
throw new NotFoundException('Batch not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batch.status === BatchStatus.PROCESSING) {
|
|
||||||
throw new BadRequestException('Batch is currently processing');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batch.images.length === 0) {
|
|
||||||
throw new BadRequestException('No failed images to retry');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset failed images to pending
|
|
||||||
await this.prisma.image.updateMany({
|
|
||||||
where: {
|
|
||||||
batchId,
|
|
||||||
status: ImageStatus.FAILED,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: ImageStatus.PENDING,
|
|
||||||
processingError: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update batch status
|
|
||||||
await this.prisma.batch.update({
|
|
||||||
where: { id: batchId },
|
|
||||||
data: {
|
|
||||||
status: BatchStatus.PROCESSING,
|
|
||||||
completedAt: null,
|
|
||||||
failedImages: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Queue retry processing
|
|
||||||
await this.queueService.addBatchProcessingJob({
|
|
||||||
batchId,
|
|
||||||
userId,
|
|
||||||
imageIds: batch.images.map(img => img.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Batch retry started: ${batchId} with ${batch.images.length} images`);
|
|
||||||
|
|
||||||
return batch.images.length;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof NotFoundException || error instanceof BadRequestException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
this.logger.error(`Failed to retry batch: ${batchId}`, error.stack);
|
|
||||||
throw new BadRequestException('Failed to retry batch');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate download link for processed batch
|
|
||||||
*/
|
|
||||||
async generateBatchDownload(batchId: string, userId: string): Promise<{
|
|
||||||
download_url: string;
|
|
||||||
expires_at: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const batch = await this.prisma.batch.findFirst({
|
|
||||||
where: {
|
|
||||||
id: batchId,
|
|
||||||
userId,
|
|
||||||
status: BatchStatus.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<void> {
|
|
||||||
try {
|
|
||||||
const batch = await this.prisma.batch.findUnique({
|
|
||||||
where: { id: batchId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!batch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isComplete = (processedImages + failedImages) >= batch.totalImages;
|
|
||||||
const newStatus = isComplete ?
|
|
||||||
(failedImages === batch.totalImages ? BatchStatus.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<string>('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<boolean> {
|
|
||||||
try {
|
|
||||||
await this.$queryRaw`SELECT 1`;
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Database health check failed:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get database statistics
|
|
||||||
*/
|
|
||||||
async getDatabaseStats() {
|
|
||||||
try {
|
|
||||||
const [userCount, batchCount, imageCount, paymentCount] = await Promise.all([
|
|
||||||
this.user.count(),
|
|
||||||
this.batch.count(),
|
|
||||||
this.image.count(),
|
|
||||||
this.payment.count(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
users: userCount,
|
|
||||||
batches: batchCount,
|
|
||||||
images: imageCount,
|
|
||||||
payments: paymentCount,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get database stats:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transaction helper method
|
|
||||||
*/
|
|
||||||
async transaction<T>(fn: (prisma: PrismaClient) => Promise<T>): Promise<T> {
|
|
||||||
return this.$transaction(fn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Batch, BatchStatus, Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../prisma.service';
|
|
||||||
import { CreateBatchDto, UpdateBatchDto } from '../../batches/batch.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BatchRepository {
|
|
||||||
private readonly logger = new Logger(BatchRepository.name);
|
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new batch
|
|
||||||
*/
|
|
||||||
async create(data: CreateBatchDto): Promise<Batch> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.batch.create({
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
status: BatchStatus.PROCESSING,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to create batch:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find batch by ID
|
|
||||||
*/
|
|
||||||
async findById(id: string): Promise<Batch | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.batch.findUnique({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find batch by ID ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update batch
|
|
||||||
*/
|
|
||||||
async update(id: string, data: UpdateBatchDto): Promise<Batch> {
|
|
||||||
try {
|
|
||||||
const updateData: any = { ...data };
|
|
||||||
|
|
||||||
// Set completedAt if status is changing to DONE or ERROR
|
|
||||||
if (data.status && (data.status === BatchStatus.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<Batch> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.batch.delete({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to delete batch ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find batches with pagination
|
|
||||||
*/
|
|
||||||
async findMany(params: {
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
where?: Prisma.BatchWhereInput;
|
|
||||||
orderBy?: Prisma.BatchOrderByWithRelationInput;
|
|
||||||
}): Promise<Batch[]> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.batch.findMany({
|
|
||||||
skip: params.skip,
|
|
||||||
take: params.take,
|
|
||||||
where: params.where,
|
|
||||||
orderBy: params.orderBy,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to find batches:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find batches by user ID
|
|
||||||
*/
|
|
||||||
async findByUserId(
|
|
||||||
userId: string,
|
|
||||||
params?: {
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
status?: BatchStatus;
|
|
||||||
orderBy?: Prisma.BatchOrderByWithRelationInput;
|
|
||||||
}
|
|
||||||
): Promise<Batch[]> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.batch.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
...(params?.status && { status: params.status }),
|
|
||||||
},
|
|
||||||
skip: params?.skip,
|
|
||||||
take: params?.take,
|
|
||||||
orderBy: params?.orderBy || { createdAt: 'desc' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find batches for user ${userId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count batches
|
|
||||||
*/
|
|
||||||
async count(where?: Prisma.BatchWhereInput): Promise<number> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.batch.count({ where });
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to count batches:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find batch with images
|
|
||||||
*/
|
|
||||||
async findByIdWithImages(id: string): Promise<Batch & {
|
|
||||||
images: any[];
|
|
||||||
user: any;
|
|
||||||
_count: { images: number };
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.batch.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
images: {
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
plan: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: { images: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find batch with images ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update batch progress
|
|
||||||
*/
|
|
||||||
async updateProgress(id: string, processedImages: number, failedImages: number): Promise<Batch> {
|
|
||||||
try {
|
|
||||||
const batch = await this.findById(id);
|
|
||||||
if (!batch) {
|
|
||||||
throw new Error(`Batch ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if batch is complete
|
|
||||||
const totalProcessed = processedImages + failedImages;
|
|
||||||
const isComplete = totalProcessed >= batch.totalImages;
|
|
||||||
|
|
||||||
const updateData: any = {
|
|
||||||
processedImages,
|
|
||||||
failedImages,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isComplete) {
|
|
||||||
updateData.status = failedImages === batch.totalImages ? BatchStatus.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<Batch> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.batch.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
processedImages: { increment: 1 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to increment processed images for batch ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Increment failed images count
|
|
||||||
*/
|
|
||||||
async incrementFailedImages(id: string): Promise<Batch> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.batch.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
failedImages: { increment: 1 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to increment failed images for batch ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find processing batches (for cleanup/monitoring)
|
|
||||||
*/
|
|
||||||
async findProcessingBatches(olderThanMinutes?: number): Promise<Batch[]> {
|
|
||||||
try {
|
|
||||||
const where: Prisma.BatchWhereInput = {
|
|
||||||
status: BatchStatus.PROCESSING,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (olderThanMinutes) {
|
|
||||||
const cutoffTime = new Date();
|
|
||||||
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
|
|
||||||
where.createdAt = { lte: cutoffTime };
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.prisma.batch.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to find processing batches:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get batch statistics
|
|
||||||
*/
|
|
||||||
async getBatchStats(batchId: string): Promise<{
|
|
||||||
totalImages: number;
|
|
||||||
processedImages: number;
|
|
||||||
failedImages: number;
|
|
||||||
pendingImages: number;
|
|
||||||
progressPercentage: number;
|
|
||||||
averageProcessingTime?: number;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const batch = await this.findByIdWithImages(batchId);
|
|
||||||
if (!batch) {
|
|
||||||
throw new Error(`Batch ${batchId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingImages = batch.totalImages - batch.processedImages - batch.failedImages;
|
|
||||||
const progressPercentage = Math.round(
|
|
||||||
((batch.processedImages + batch.failedImages) / batch.totalImages) * 100
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate average processing time from completed images
|
|
||||||
const completedImages = batch.images.filter(img => img.processedAt);
|
|
||||||
let averageProcessingTime: number | undefined;
|
|
||||||
|
|
||||||
if (completedImages.length > 0) {
|
|
||||||
const totalProcessingTime = completedImages.reduce((sum, img) => {
|
|
||||||
const processingTime = img.processedAt.getTime() - img.createdAt.getTime();
|
|
||||||
return sum + processingTime;
|
|
||||||
}, 0);
|
|
||||||
averageProcessingTime = totalProcessingTime / completedImages.length / 1000; // Convert to seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalImages: batch.totalImages,
|
|
||||||
processedImages: batch.processedImages,
|
|
||||||
failedImages: batch.failedImages,
|
|
||||||
pendingImages,
|
|
||||||
progressPercentage,
|
|
||||||
averageProcessingTime,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get batch stats for ${batchId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user batch statistics
|
|
||||||
*/
|
|
||||||
async getUserBatchStats(userId: string): Promise<{
|
|
||||||
totalBatches: number;
|
|
||||||
completedBatches: number;
|
|
||||||
processingBatches: number;
|
|
||||||
errorBatches: number;
|
|
||||||
totalImages: number;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const [totalBatches, completedBatches, processingBatches, errorBatches, imageStats] = await Promise.all([
|
|
||||||
this.count({ userId }),
|
|
||||||
this.count({ userId, status: BatchStatus.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,457 +0,0 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Image, ImageStatus, Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../prisma.service';
|
|
||||||
import { CreateImageDto, UpdateImageDto } from '../../images/image.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ImageRepository {
|
|
||||||
private readonly logger = new Logger(ImageRepository.name);
|
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new image
|
|
||||||
*/
|
|
||||||
async create(data: CreateImageDto): Promise<Image> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.image.create({
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
status: ImageStatus.PENDING,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} 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<Image | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.image.findUnique({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find image by ID ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update image
|
|
||||||
*/
|
|
||||||
async update(id: string, data: UpdateImageDto): Promise<Image> {
|
|
||||||
try {
|
|
||||||
const updateData: any = { ...data };
|
|
||||||
|
|
||||||
// Set processedAt if status is changing to COMPLETED or FAILED
|
|
||||||
if (data.status && (data.status === ImageStatus.COMPLETED || data.status === ImageStatus.FAILED)) {
|
|
||||||
updateData.processedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.prisma.image.update({
|
|
||||||
where: { id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to update image ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete image
|
|
||||||
*/
|
|
||||||
async delete(id: string): Promise<Image> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.image.delete({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to delete image ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find images with pagination
|
|
||||||
*/
|
|
||||||
async findMany(params: {
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
where?: Prisma.ImageWhereInput;
|
|
||||||
orderBy?: Prisma.ImageOrderByWithRelationInput;
|
|
||||||
}): Promise<Image[]> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.image.findMany({
|
|
||||||
skip: params.skip,
|
|
||||||
take: params.take,
|
|
||||||
where: params.where,
|
|
||||||
orderBy: params.orderBy,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to find images:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find images by batch ID
|
|
||||||
*/
|
|
||||||
async findByBatchId(
|
|
||||||
batchId: string,
|
|
||||||
params?: {
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
status?: ImageStatus;
|
|
||||||
orderBy?: Prisma.ImageOrderByWithRelationInput;
|
|
||||||
}
|
|
||||||
): Promise<Image[]> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.image.findMany({
|
|
||||||
where: {
|
|
||||||
batchId,
|
|
||||||
...(params?.status && { status: params.status }),
|
|
||||||
},
|
|
||||||
skip: params?.skip,
|
|
||||||
take: params?.take,
|
|
||||||
orderBy: params?.orderBy || { createdAt: 'asc' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find images for batch ${batchId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count images
|
|
||||||
*/
|
|
||||||
async count(where?: Prisma.ImageWhereInput): Promise<number> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.image.count({ where });
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to count images:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find image with batch info
|
|
||||||
*/
|
|
||||||
async findByIdWithBatch(id: string): Promise<Image & {
|
|
||||||
batch: any;
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.image.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
batch: {
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
plan: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find image with batch ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update image status
|
|
||||||
*/
|
|
||||||
async updateStatus(id: string, status: ImageStatus, error?: string): Promise<Image> {
|
|
||||||
try {
|
|
||||||
const updateData: any = {
|
|
||||||
status,
|
|
||||||
...(error && { processingError: error }),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
|
|
||||||
updateData.processedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.prisma.image.update({
|
|
||||||
where: { id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to update image status ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bulk update image statuses
|
|
||||||
*/
|
|
||||||
async bulkUpdateStatus(imageIds: string[], status: ImageStatus): Promise<{ count: number }> {
|
|
||||||
try {
|
|
||||||
const updateData: any = { status };
|
|
||||||
|
|
||||||
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
|
|
||||||
updateData.processedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.prisma.image.updateMany({
|
|
||||||
where: {
|
|
||||||
id: { in: imageIds },
|
|
||||||
},
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to bulk update image statuses:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply proposed names as final names
|
|
||||||
*/
|
|
||||||
async applyProposedNames(imageIds: string[]): Promise<{ count: number }> {
|
|
||||||
try {
|
|
||||||
// First, get all images with their proposed names
|
|
||||||
const images = await this.prisma.image.findMany({
|
|
||||||
where: {
|
|
||||||
id: { in: imageIds },
|
|
||||||
proposedName: { not: null },
|
|
||||||
},
|
|
||||||
select: { id: true, proposedName: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use transaction to update each image with its proposed name as final name
|
|
||||||
const results = await this.prisma.$transaction(
|
|
||||||
images.map(image =>
|
|
||||||
this.prisma.image.update({
|
|
||||||
where: { id: image.id },
|
|
||||||
data: { finalName: image.proposedName },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { count: results.length };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to apply proposed names:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find pending images for processing
|
|
||||||
*/
|
|
||||||
async findPendingImages(limit?: number): Promise<Image[]> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.image.findMany({
|
|
||||||
where: {
|
|
||||||
status: ImageStatus.PENDING,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
take: limit,
|
|
||||||
include: {
|
|
||||||
batch: {
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
plan: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to find pending images:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find processing images (for cleanup/monitoring)
|
|
||||||
*/
|
|
||||||
async findProcessingImages(olderThanMinutes?: number): Promise<Image[]> {
|
|
||||||
try {
|
|
||||||
const where: Prisma.ImageWhereInput = {
|
|
||||||
status: ImageStatus.PROCESSING,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (olderThanMinutes) {
|
|
||||||
const cutoffTime = new Date();
|
|
||||||
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
|
|
||||||
where.updatedAt = { lte: cutoffTime };
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.prisma.image.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: { updatedAt: 'asc' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to find processing images:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get image processing statistics for a batch
|
|
||||||
*/
|
|
||||||
async getBatchImageStats(batchId: string): Promise<{
|
|
||||||
total: number;
|
|
||||||
pending: number;
|
|
||||||
processing: number;
|
|
||||||
completed: number;
|
|
||||||
failed: number;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const [total, pending, processing, completed, failed] = await Promise.all([
|
|
||||||
this.count({ batchId }),
|
|
||||||
this.count({ batchId, status: ImageStatus.PENDING }),
|
|
||||||
this.count({ batchId, status: ImageStatus.PROCESSING }),
|
|
||||||
this.count({ batchId, status: ImageStatus.COMPLETED }),
|
|
||||||
this.count({ batchId, status: ImageStatus.FAILED }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
total,
|
|
||||||
pending,
|
|
||||||
processing,
|
|
||||||
completed,
|
|
||||||
failed,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get batch image stats for ${batchId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user image processing statistics
|
|
||||||
*/
|
|
||||||
async getUserImageStats(userId: string): Promise<{
|
|
||||||
totalImages: number;
|
|
||||||
completedImages: number;
|
|
||||||
failedImages: number;
|
|
||||||
processingImages: number;
|
|
||||||
pendingImages: number;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const [
|
|
||||||
totalImages,
|
|
||||||
completedImages,
|
|
||||||
failedImages,
|
|
||||||
processingImages,
|
|
||||||
pendingImages,
|
|
||||||
] = await Promise.all([
|
|
||||||
this.prisma.image.count({
|
|
||||||
where: {
|
|
||||||
batch: { userId },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.image.count({
|
|
||||||
where: {
|
|
||||||
batch: { userId },
|
|
||||||
status: ImageStatus.COMPLETED,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.image.count({
|
|
||||||
where: {
|
|
||||||
batch: { userId },
|
|
||||||
status: ImageStatus.FAILED,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.image.count({
|
|
||||||
where: {
|
|
||||||
batch: { userId },
|
|
||||||
status: ImageStatus.PROCESSING,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.prisma.image.count({
|
|
||||||
where: {
|
|
||||||
batch: { userId },
|
|
||||||
status: ImageStatus.PENDING,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalImages,
|
|
||||||
completedImages,
|
|
||||||
failedImages,
|
|
||||||
processingImages,
|
|
||||||
pendingImages,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get user image stats for ${userId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search images by original name
|
|
||||||
*/
|
|
||||||
async searchByOriginalName(
|
|
||||||
searchTerm: string,
|
|
||||||
userId?: string,
|
|
||||||
params?: { skip?: number; take?: number }
|
|
||||||
): Promise<Image[]> {
|
|
||||||
try {
|
|
||||||
const where: Prisma.ImageWhereInput = {
|
|
||||||
originalName: {
|
|
||||||
contains: searchTerm,
|
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
|
||||||
...(userId && {
|
|
||||||
batch: { userId },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return await this.prisma.image.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
skip: params?.skip,
|
|
||||||
take: params?.take,
|
|
||||||
include: {
|
|
||||||
batch: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
status: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to search images by original name:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,437 +0,0 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Payment, PaymentStatus, Plan, Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../prisma.service';
|
|
||||||
import { CreatePaymentDto, UpdatePaymentDto } from '../../payments/payment.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PaymentRepository {
|
|
||||||
private readonly logger = new Logger(PaymentRepository.name);
|
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new payment
|
|
||||||
*/
|
|
||||||
async create(data: CreatePaymentDto): Promise<Payment> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.payment.create({
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
status: PaymentStatus.PENDING,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to create payment:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find payment by ID
|
|
||||||
*/
|
|
||||||
async findById(id: string): Promise<Payment | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.payment.findUnique({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find payment by ID ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find payment by Stripe Session ID
|
|
||||||
*/
|
|
||||||
async findByStripeSessionId(stripeSessionId: string): Promise<Payment | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.payment.findUnique({
|
|
||||||
where: { stripeSessionId },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find payment by Stripe Session ID ${stripeSessionId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find payment by Stripe Payment ID
|
|
||||||
*/
|
|
||||||
async findByStripePaymentId(stripePaymentId: string): Promise<Payment | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.payment.findUnique({
|
|
||||||
where: { stripePaymentId },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find payment by Stripe Payment ID ${stripePaymentId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update payment
|
|
||||||
*/
|
|
||||||
async update(id: string, data: UpdatePaymentDto): Promise<Payment> {
|
|
||||||
try {
|
|
||||||
const updateData: any = { ...data };
|
|
||||||
|
|
||||||
// Set paidAt if status is changing to COMPLETED
|
|
||||||
if (data.status === PaymentStatus.COMPLETED) {
|
|
||||||
updateData.paidAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.prisma.payment.update({
|
|
||||||
where: { id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to update payment ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete payment
|
|
||||||
*/
|
|
||||||
async delete(id: string): Promise<Payment> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.payment.delete({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to delete payment ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find payments with pagination
|
|
||||||
*/
|
|
||||||
async findMany(params: {
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
where?: Prisma.PaymentWhereInput;
|
|
||||||
orderBy?: Prisma.PaymentOrderByWithRelationInput;
|
|
||||||
}): Promise<Payment[]> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.payment.findMany({
|
|
||||||
skip: params.skip,
|
|
||||||
take: params.take,
|
|
||||||
where: params.where,
|
|
||||||
orderBy: params.orderBy,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to find payments:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find payments by user ID
|
|
||||||
*/
|
|
||||||
async findByUserId(
|
|
||||||
userId: string,
|
|
||||||
params?: {
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
status?: PaymentStatus;
|
|
||||||
orderBy?: Prisma.PaymentOrderByWithRelationInput;
|
|
||||||
}
|
|
||||||
): Promise<Payment[]> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.payment.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
...(params?.status && { status: params.status }),
|
|
||||||
},
|
|
||||||
skip: params?.skip,
|
|
||||||
take: params?.take,
|
|
||||||
orderBy: params?.orderBy || { createdAt: 'desc' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find payments for user ${userId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count payments
|
|
||||||
*/
|
|
||||||
async count(where?: Prisma.PaymentWhereInput): Promise<number> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.payment.count({ where });
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to count payments:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find payment with user info
|
|
||||||
*/
|
|
||||||
async findByIdWithUser(id: string): Promise<Payment & {
|
|
||||||
user: any;
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.payment.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
plan: true,
|
|
||||||
quotaRemaining: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find payment with user ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update payment status
|
|
||||||
*/
|
|
||||||
async updateStatus(id: string, status: PaymentStatus, stripePaymentId?: string): Promise<Payment> {
|
|
||||||
try {
|
|
||||||
const updateData: any = { status };
|
|
||||||
|
|
||||||
if (stripePaymentId) {
|
|
||||||
updateData.stripePaymentId = stripePaymentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === PaymentStatus.COMPLETED) {
|
|
||||||
updateData.paidAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.prisma.payment.update({
|
|
||||||
where: { id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to update payment status ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find successful payments by user
|
|
||||||
*/
|
|
||||||
async findSuccessfulPaymentsByUserId(userId: string): Promise<Payment[]> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.payment.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
status: PaymentStatus.COMPLETED,
|
|
||||||
},
|
|
||||||
orderBy: { paidAt: 'desc' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find successful payments for user ${userId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user payment statistics
|
|
||||||
*/
|
|
||||||
async getUserPaymentStats(userId: string): Promise<{
|
|
||||||
totalPayments: number;
|
|
||||||
successfulPayments: number;
|
|
||||||
failedPayments: number;
|
|
||||||
totalAmountSpent: number;
|
|
||||||
lastPaymentDate?: Date;
|
|
||||||
averagePaymentAmount: number;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const [
|
|
||||||
totalPayments,
|
|
||||||
successfulPayments,
|
|
||||||
failedPayments,
|
|
||||||
amountStats,
|
|
||||||
lastSuccessfulPayment,
|
|
||||||
] = await Promise.all([
|
|
||||||
this.count({ userId }),
|
|
||||||
this.count({ userId, status: PaymentStatus.COMPLETED }),
|
|
||||||
this.count({
|
|
||||||
userId,
|
|
||||||
status: { in: [PaymentStatus.FAILED, PaymentStatus.CANCELLED] }
|
|
||||||
}),
|
|
||||||
this.prisma.payment.aggregate({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
status: PaymentStatus.COMPLETED
|
|
||||||
},
|
|
||||||
_sum: { amount: true },
|
|
||||||
_avg: { amount: true },
|
|
||||||
}),
|
|
||||||
this.prisma.payment.findFirst({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
status: PaymentStatus.COMPLETED
|
|
||||||
},
|
|
||||||
orderBy: { paidAt: 'desc' },
|
|
||||||
select: { paidAt: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalPayments,
|
|
||||||
successfulPayments,
|
|
||||||
failedPayments,
|
|
||||||
totalAmountSpent: amountStats._sum.amount || 0,
|
|
||||||
lastPaymentDate: lastSuccessfulPayment?.paidAt || undefined,
|
|
||||||
averagePaymentAmount: Math.round(amountStats._avg.amount || 0),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get user payment stats for ${userId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find pending payments (for cleanup/monitoring)
|
|
||||||
*/
|
|
||||||
async findPendingPayments(olderThanMinutes?: number): Promise<Payment[]> {
|
|
||||||
try {
|
|
||||||
const where: Prisma.PaymentWhereInput = {
|
|
||||||
status: PaymentStatus.PENDING,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (olderThanMinutes) {
|
|
||||||
const cutoffTime = new Date();
|
|
||||||
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
|
|
||||||
where.createdAt = { lte: cutoffTime };
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.prisma.payment.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to find pending payments:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get revenue statistics
|
|
||||||
*/
|
|
||||||
async getRevenueStats(params?: {
|
|
||||||
startDate?: Date;
|
|
||||||
endDate?: Date;
|
|
||||||
plan?: Plan;
|
|
||||||
}): Promise<{
|
|
||||||
totalRevenue: number;
|
|
||||||
totalPayments: number;
|
|
||||||
averagePaymentAmount: number;
|
|
||||||
revenueByPlan: Record<Plan, number>;
|
|
||||||
paymentsCount: Record<PaymentStatus, number>;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const where: Prisma.PaymentWhereInput = {
|
|
||||||
status: PaymentStatus.COMPLETED,
|
|
||||||
...(params?.startDate && { createdAt: { gte: params.startDate } }),
|
|
||||||
...(params?.endDate && { createdAt: { lte: params.endDate } }),
|
|
||||||
...(params?.plan && { plan: params.plan }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const [revenueStats, revenueByPlan, paymentStatusCounts] = await Promise.all([
|
|
||||||
this.prisma.payment.aggregate({
|
|
||||||
where,
|
|
||||||
_sum: { amount: true },
|
|
||||||
_count: true,
|
|
||||||
_avg: { amount: true },
|
|
||||||
}),
|
|
||||||
this.prisma.payment.groupBy({
|
|
||||||
by: ['plan'],
|
|
||||||
where,
|
|
||||||
_sum: { amount: true },
|
|
||||||
}),
|
|
||||||
this.prisma.payment.groupBy({
|
|
||||||
by: ['status'],
|
|
||||||
_count: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const revenueByPlanMap = Object.values(Plan).reduce((acc, plan) => {
|
|
||||||
acc[plan] = 0;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<Plan, number>);
|
|
||||||
|
|
||||||
revenueByPlan.forEach(item => {
|
|
||||||
revenueByPlanMap[item.plan] = item._sum.amount || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const paymentsCountMap = Object.values(PaymentStatus).reduce((acc, status) => {
|
|
||||||
acc[status] = 0;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<PaymentStatus, number>);
|
|
||||||
|
|
||||||
paymentStatusCounts.forEach(item => {
|
|
||||||
paymentsCountMap[item.status] = item._count;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalRevenue: revenueStats._sum.amount || 0,
|
|
||||||
totalPayments: revenueStats._count,
|
|
||||||
averagePaymentAmount: Math.round(revenueStats._avg.amount || 0),
|
|
||||||
revenueByPlan: revenueByPlanMap,
|
|
||||||
paymentsCount: paymentsCountMap,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get revenue stats:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find payments by date range
|
|
||||||
*/
|
|
||||||
async findPaymentsByDateRange(
|
|
||||||
startDate: Date,
|
|
||||||
endDate: Date,
|
|
||||||
params?: {
|
|
||||||
userId?: string;
|
|
||||||
status?: PaymentStatus;
|
|
||||||
plan?: Plan;
|
|
||||||
}
|
|
||||||
): Promise<Payment[]> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.payment.findMany({
|
|
||||||
where: {
|
|
||||||
createdAt: {
|
|
||||||
gte: startDate,
|
|
||||||
lte: endDate,
|
|
||||||
},
|
|
||||||
...(params?.userId && { userId: params.userId }),
|
|
||||||
...(params?.status && { status: params.status }),
|
|
||||||
...(params?.plan && { plan: params.plan }),
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to find payments by date range:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<User> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.create({
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
plan: data.plan || Plan.BASIC,
|
|
||||||
quotaRemaining: data.quotaRemaining || this.getQuotaForPlan(data.plan || Plan.BASIC),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to create user:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find user by ID
|
|
||||||
*/
|
|
||||||
async findById(id: string): Promise<User | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.findUnique({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find user by ID ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find user by email
|
|
||||||
*/
|
|
||||||
async findByEmail(email: string): Promise<User | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.findUnique({
|
|
||||||
where: { email },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find user by email ${email}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find user by Google UID
|
|
||||||
*/
|
|
||||||
async findByGoogleUid(googleUid: string): Promise<User | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.findUnique({
|
|
||||||
where: { googleUid },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find user by Google UID ${googleUid}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find user by email hash
|
|
||||||
*/
|
|
||||||
async findByEmailHash(emailHash: string): Promise<User | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.findUnique({
|
|
||||||
where: { emailHash },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find user by email hash:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user
|
|
||||||
*/
|
|
||||||
async update(id: string, data: UpdateUserDto): Promise<User> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.update({
|
|
||||||
where: { id },
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to update user ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete user
|
|
||||||
*/
|
|
||||||
async delete(id: string): Promise<User> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.delete({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to delete user ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find users with pagination
|
|
||||||
*/
|
|
||||||
async findMany(params: {
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
where?: Prisma.UserWhereInput;
|
|
||||||
orderBy?: Prisma.UserOrderByWithRelationInput;
|
|
||||||
}): Promise<User[]> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.findMany({
|
|
||||||
skip: params.skip,
|
|
||||||
take: params.take,
|
|
||||||
where: params.where,
|
|
||||||
orderBy: params.orderBy,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to find users:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count users
|
|
||||||
*/
|
|
||||||
async count(where?: Prisma.UserWhereInput): Promise<number> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.count({ where });
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to count users:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user quota
|
|
||||||
*/
|
|
||||||
async updateQuota(id: string, quotaRemaining: number): Promise<User> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.update({
|
|
||||||
where: { id },
|
|
||||||
data: { quotaRemaining },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to update quota for user ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduct quota from user
|
|
||||||
*/
|
|
||||||
async deductQuota(id: string, amount: number): Promise<User> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
quotaRemaining: {
|
|
||||||
decrement: amount,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to deduct quota for user ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset user quota (monthly reset)
|
|
||||||
*/
|
|
||||||
async resetQuota(id: string): Promise<User> {
|
|
||||||
try {
|
|
||||||
const user = await this.findById(id);
|
|
||||||
if (!user) {
|
|
||||||
throw new Error(`User ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newQuota = this.getQuotaForPlan(user.plan);
|
|
||||||
const nextResetDate = this.calculateNextResetDate();
|
|
||||||
|
|
||||||
return await this.prisma.user.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
quotaRemaining: newQuota,
|
|
||||||
quotaResetDate: nextResetDate,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to reset quota for user ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upgrade user plan
|
|
||||||
*/
|
|
||||||
async upgradePlan(id: string, newPlan: Plan): Promise<User> {
|
|
||||||
try {
|
|
||||||
const newQuota = this.getQuotaForPlan(newPlan);
|
|
||||||
|
|
||||||
return await this.prisma.user.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
plan: newPlan,
|
|
||||||
quotaRemaining: newQuota,
|
|
||||||
quotaResetDate: this.calculateNextResetDate(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to upgrade plan for user ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find users with expired quotas
|
|
||||||
*/
|
|
||||||
async findUsersWithExpiredQuotas(): Promise<User[]> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.findMany({
|
|
||||||
where: {
|
|
||||||
quotaResetDate: {
|
|
||||||
lte: new Date(),
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to find users with expired quotas:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user with related data
|
|
||||||
*/
|
|
||||||
async findByIdWithRelations(id: string): Promise<User & {
|
|
||||||
batches: any[];
|
|
||||||
payments: any[];
|
|
||||||
_count: {
|
|
||||||
batches: number;
|
|
||||||
payments: number;
|
|
||||||
};
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
batches: {
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 5,
|
|
||||||
},
|
|
||||||
payments: {
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 5,
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
batches: true,
|
|
||||||
payments: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find user with relations ${id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Get quota for plan
|
|
||||||
*/
|
|
||||||
private getQuotaForPlan(plan: Plan): number {
|
|
||||||
switch (plan) {
|
|
||||||
case Plan.BASIC:
|
|
||||||
return 50;
|
|
||||||
case Plan.PRO:
|
|
||||||
return 500;
|
|
||||||
case Plan.MAX:
|
|
||||||
return 1000;
|
|
||||||
default:
|
|
||||||
return 50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Link Google account to existing user
|
|
||||||
*/
|
|
||||||
async linkGoogleAccount(userId: string, googleUid: string): Promise<User> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { googleUid },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to link Google account for user ${userId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user's last login timestamp
|
|
||||||
*/
|
|
||||||
async updateLastLogin(userId: string): Promise<User> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { updatedAt: new Date() },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to update last login for user ${userId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user's last activity timestamp
|
|
||||||
*/
|
|
||||||
async updateLastActivity(userId: string): Promise<User> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { updatedAt: new Date() },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to update last activity for user ${userId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create user with OAuth data
|
|
||||||
*/
|
|
||||||
async createWithOAuth(data: {
|
|
||||||
googleUid: string;
|
|
||||||
email: string;
|
|
||||||
emailHash: string;
|
|
||||||
plan: Plan;
|
|
||||||
quotaRemaining: number;
|
|
||||||
quotaResetDate: Date;
|
|
||||||
isActive: boolean;
|
|
||||||
}): Promise<User> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.create({
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to create user with OAuth data:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: Calculate next quota reset date (first day of next month)
|
|
||||||
*/
|
|
||||||
private calculateNextResetDate(): Date {
|
|
||||||
const now = new Date();
|
|
||||||
return new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<DownloadInfo> {
|
|
||||||
try {
|
|
||||||
// Validate batch ownership and completion
|
|
||||||
const batch = await this.batchRepository.findById(batchId);
|
|
||||||
if (!batch) {
|
|
||||||
throw new NotFoundException('Batch not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batch.userId !== userId) {
|
|
||||||
throw new ForbiddenException('Access denied to this batch');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batch.status !== 'COMPLETED') {
|
|
||||||
throw new Error('Batch is not completed yet');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get batch images
|
|
||||||
const images = await this.imageRepository.findByBatchId(batchId);
|
|
||||||
if (images.length === 0) {
|
|
||||||
throw new Error('No images found in batch');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create download record
|
|
||||||
const downloadId = uuidv4();
|
|
||||||
const expiresAt = new Date();
|
|
||||||
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hour expiry
|
|
||||||
|
|
||||||
// Calculate total size
|
|
||||||
let totalSize = 0;
|
|
||||||
for (const image of images) {
|
|
||||||
if (image.processedImageUrl) {
|
|
||||||
try {
|
|
||||||
const size = await this.storageService.getFileSize(image.processedImageUrl);
|
|
||||||
totalSize += size;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to get size for ${image.processedImageUrl}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store download info in database
|
|
||||||
const download = await this.prisma.download.create({
|
|
||||||
data: {
|
|
||||||
id: downloadId,
|
|
||||||
userId,
|
|
||||||
batchId,
|
|
||||||
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<DownloadStream> {
|
|
||||||
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<void> {
|
|
||||||
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<DownloadInfo> {
|
|
||||||
try {
|
|
||||||
const oldDownload = await this.prisma.download.findUnique({
|
|
||||||
where: { id: oldDownloadId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!oldDownload) {
|
|
||||||
throw new NotFoundException('Download not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldDownload.userId !== userId) {
|
|
||||||
throw new ForbiddenException('Access denied to this download');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new download for the same batch
|
|
||||||
return await this.createDownload(userId, oldDownload.batchId);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to regenerate download ${oldDownloadId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preview batch contents
|
|
||||||
*/
|
|
||||||
async previewBatch(userId: string, batchId: string) {
|
|
||||||
try {
|
|
||||||
// Validate batch ownership
|
|
||||||
const batch = await this.batchRepository.findById(batchId);
|
|
||||||
if (!batch) {
|
|
||||||
throw new NotFoundException('Batch not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batch.userId !== userId) {
|
|
||||||
throw new ForbiddenException('Access denied to this batch');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get batch images
|
|
||||||
const images = await this.imageRepository.findByBatchId(batchId);
|
|
||||||
|
|
||||||
let totalSize = 0;
|
|
||||||
const fileList = [];
|
|
||||||
|
|
||||||
for (const image of images) {
|
|
||||||
let fileSize = 0;
|
|
||||||
if (image.processedImageUrl) {
|
|
||||||
try {
|
|
||||||
fileSize = await this.storageService.getFileSize(image.processedImageUrl);
|
|
||||||
totalSize += fileSize;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to get size for ${image.processedImageUrl}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileList.push({
|
|
||||||
originalName: image.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<number> {
|
|
||||||
try {
|
|
||||||
const expiredDownloads = await this.prisma.download.findMany({
|
|
||||||
where: {
|
|
||||||
expiresAt: {
|
|
||||||
lt: new Date(),
|
|
||||||
},
|
|
||||||
status: 'READY',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark as expired
|
|
||||||
const result = await this.prisma.download.updateMany({
|
|
||||||
where: {
|
|
||||||
id: {
|
|
||||||
in: expiredDownloads.map(d => d.id),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: 'EXPIRED',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Cleaned up ${result.count} expired downloads`);
|
|
||||||
return result.count;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to cleanup expired downloads:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate download URL
|
|
||||||
*/
|
|
||||||
private generateDownloadUrl(downloadId: string): string {
|
|
||||||
const baseUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
|
|
||||||
return `${baseUrl}/api/downloads/${downloadId}/direct`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get download analytics
|
|
||||||
*/
|
|
||||||
async getDownloadAnalytics(startDate?: Date, endDate?: Date) {
|
|
||||||
try {
|
|
||||||
const whereClause: any = {};
|
|
||||||
|
|
||||||
if (startDate && endDate) {
|
|
||||||
whereClause.createdAt = {
|
|
||||||
gte: startDate,
|
|
||||||
lte: endDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const [
|
|
||||||
totalDownloads,
|
|
||||||
totalFiles,
|
|
||||||
totalSize,
|
|
||||||
downloadsPerDay,
|
|
||||||
] = await Promise.all([
|
|
||||||
this.prisma.download.count({ where: whereClause }),
|
|
||||||
|
|
||||||
this.prisma.download.aggregate({
|
|
||||||
where: whereClause,
|
|
||||||
_sum: {
|
|
||||||
fileCount: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
this.prisma.download.aggregate({
|
|
||||||
where: whereClause,
|
|
||||||
_sum: {
|
|
||||||
totalSize: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
this.prisma.download.groupBy({
|
|
||||||
by: ['createdAt'],
|
|
||||||
where: whereClause,
|
|
||||||
_count: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalDownloads,
|
|
||||||
totalFiles: totalFiles._sum.fileCount || 0,
|
|
||||||
totalSize: totalSize._sum.totalSize || 0,
|
|
||||||
downloadsPerDay: downloadsPerDay.map(item => ({
|
|
||||||
date: item.createdAt,
|
|
||||||
count: item._count.id,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get download analytics:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Readable, Transform } from 'stream';
|
|
||||||
import * as sharp from 'sharp';
|
|
||||||
import { StorageService } from '../../storage/storage.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ExifService {
|
|
||||||
private readonly logger = new Logger(ExifService.name);
|
|
||||||
|
|
||||||
constructor(private readonly storageService: StorageService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preserve EXIF data from original image to processed image
|
|
||||||
*/
|
|
||||||
async preserveExifData(processedStream: Readable, originalImagePath: string): Promise<Readable> {
|
|
||||||
try {
|
|
||||||
// Get original image buffer to extract EXIF
|
|
||||||
const originalBuffer = await this.storageService.getFileBuffer(originalImagePath);
|
|
||||||
|
|
||||||
// Extract EXIF data from original
|
|
||||||
const originalMetadata = await sharp(originalBuffer).metadata();
|
|
||||||
|
|
||||||
if (!originalMetadata.exif && !originalMetadata.icc && !originalMetadata.iptc) {
|
|
||||||
this.logger.debug('No EXIF data found in original image');
|
|
||||||
return processedStream;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create transform stream to add EXIF data
|
|
||||||
const exifTransform = new Transform({
|
|
||||||
transform(chunk, encoding, callback) {
|
|
||||||
this.push(chunk);
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert stream to buffer, add EXIF, and return as stream
|
|
||||||
const processedChunks: Buffer[] = [];
|
|
||||||
|
|
||||||
processedStream.on('data', (chunk) => {
|
|
||||||
processedChunks.push(chunk);
|
|
||||||
});
|
|
||||||
|
|
||||||
processedStream.on('end', async () => {
|
|
||||||
try {
|
|
||||||
const processedBuffer = Buffer.concat(processedChunks);
|
|
||||||
|
|
||||||
// Apply EXIF data to processed image
|
|
||||||
const imageWithExif = await this.addExifToImage(
|
|
||||||
processedBuffer,
|
|
||||||
originalMetadata,
|
|
||||||
);
|
|
||||||
|
|
||||||
exifTransform.end(imageWithExif);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to add EXIF data:', error);
|
|
||||||
// Fallback to original processed image
|
|
||||||
exifTransform.end(Buffer.concat(processedChunks));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
processedStream.on('error', (error) => {
|
|
||||||
this.logger.error('Error in processed stream:', error);
|
|
||||||
exifTransform.destroy(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return exifTransform;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to preserve EXIF data:', error);
|
|
||||||
// Return original stream if EXIF preservation fails
|
|
||||||
return processedStream;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add EXIF data to image buffer
|
|
||||||
*/
|
|
||||||
private async addExifToImage(
|
|
||||||
imageBuffer: Buffer,
|
|
||||||
originalMetadata: sharp.Metadata,
|
|
||||||
): Promise<Buffer> {
|
|
||||||
try {
|
|
||||||
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<Buffer> {
|
|
||||||
try {
|
|
||||||
const imageBuffer = await this.storageService.getFileBuffer(imagePath);
|
|
||||||
|
|
||||||
return await sharp(imageBuffer)
|
|
||||||
.jpeg({ quality: 95 }) // This removes metadata by default
|
|
||||||
.toBuffer();
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to remove EXIF data:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy EXIF data from one image to another
|
|
||||||
*/
|
|
||||||
async copyExifData(sourceImagePath: string, targetImageBuffer: Buffer): Promise<Buffer> {
|
|
||||||
try {
|
|
||||||
const sourceBuffer = await this.storageService.getFileBuffer(sourceImagePath);
|
|
||||||
const sourceMetadata = await sharp(sourceBuffer).metadata();
|
|
||||||
|
|
||||||
if (!sourceMetadata.exif) {
|
|
||||||
this.logger.debug('No EXIF data to copy');
|
|
||||||
return targetImageBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply metadata to target image
|
|
||||||
return await this.addExifToImage(targetImageBuffer, sourceMetadata);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to copy EXIF data:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate image has EXIF data
|
|
||||||
*/
|
|
||||||
async hasExifData(imagePath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const imageBuffer = await this.storageService.getFileBuffer(imagePath);
|
|
||||||
const metadata = await sharp(imageBuffer).metadata();
|
|
||||||
|
|
||||||
return !!(metadata.exif || metadata.iptc || metadata.xmp);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to check EXIF data:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse EXIF data (simplified)
|
|
||||||
*/
|
|
||||||
private parseExifData(exifBuffer: Buffer): {
|
|
||||||
make?: string;
|
|
||||||
model?: string;
|
|
||||||
dateTime?: string;
|
|
||||||
gps?: { latitude?: number; longitude?: number };
|
|
||||||
} {
|
|
||||||
// This is a simplified EXIF parser
|
|
||||||
// In production, you should use a proper EXIF parsing library
|
|
||||||
try {
|
|
||||||
// For now, return empty object
|
|
||||||
// TODO: Implement proper EXIF parsing or use a library like 'exif-parser'
|
|
||||||
return {};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn('Failed to parse EXIF buffer:', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get optimal image format for web delivery
|
|
||||||
*/
|
|
||||||
getOptimalFormat(originalFormat: string, hasTransparency: boolean = false): string {
|
|
||||||
// WebP for modern browsers (but this service focuses on download, so keep original format)
|
|
||||||
if (hasTransparency && originalFormat === 'png') {
|
|
||||||
return 'png';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (originalFormat === 'gif') {
|
|
||||||
return 'gif';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to JPEG for photos
|
|
||||||
return 'jpeg';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimate EXIF processing time
|
|
||||||
*/
|
|
||||||
estimateProcessingTime(fileSize: number): number {
|
|
||||||
// Rough estimate: 1MB takes about 100ms to process EXIF
|
|
||||||
const sizeInMB = fileSize / (1024 * 1024);
|
|
||||||
return Math.max(100, sizeInMB * 100); // Minimum 100ms
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,329 +0,0 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Readable, PassThrough } from 'stream';
|
|
||||||
import * as archiver from 'archiver';
|
|
||||||
import { StorageService } from '../../storage/storage.service';
|
|
||||||
import { ExifService } from './exif.service';
|
|
||||||
|
|
||||||
export interface ZipOptions {
|
|
||||||
preserveExif?: boolean;
|
|
||||||
compressionLevel?: number;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ZipFile {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
originalPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ZipService {
|
|
||||||
private readonly logger = new Logger(ZipService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly storageService: StorageService,
|
|
||||||
private readonly exifService: ExifService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create ZIP stream from files
|
|
||||||
*/
|
|
||||||
async createZipStream(files: ZipFile[], options: ZipOptions = {}): Promise<Readable> {
|
|
||||||
try {
|
|
||||||
const archive = archiver('zip', {
|
|
||||||
zlib: {
|
|
||||||
level: options.compressionLevel || 0, // 0 = store only, 9 = best compression
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const outputStream = new PassThrough();
|
|
||||||
|
|
||||||
// Handle archive events
|
|
||||||
archive.on('error', (err) => {
|
|
||||||
this.logger.error('Archive error:', err);
|
|
||||||
outputStream.destroy(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
archive.on('warning', (err) => {
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
this.logger.warn('Archive warning:', err);
|
|
||||||
} else {
|
|
||||||
this.logger.error('Archive warning:', err);
|
|
||||||
outputStream.destroy(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pipe archive to output stream
|
|
||||||
archive.pipe(outputStream);
|
|
||||||
|
|
||||||
// Add files to archive
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
await this.addFileToArchive(archive, file, options);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to add file ${file.name} to archive:`, error);
|
|
||||||
// Continue with other files instead of failing entire archive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finalize the archive
|
|
||||||
await archive.finalize();
|
|
||||||
|
|
||||||
this.logger.log(`ZIP stream created with ${files.length} files`);
|
|
||||||
return outputStream;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to create ZIP stream:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add file to archive with EXIF preservation
|
|
||||||
*/
|
|
||||||
private async addFileToArchive(
|
|
||||||
archive: archiver.Archiver,
|
|
||||||
file: ZipFile,
|
|
||||||
options: ZipOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Get file stream from storage
|
|
||||||
const fileStream = await this.storageService.getFileStream(file.path);
|
|
||||||
|
|
||||||
if (options.preserveExif && file.originalPath && this.isImageFile(file.name)) {
|
|
||||||
// Preserve EXIF data from original image
|
|
||||||
const processedStream = await this.exifService.preserveExifData(
|
|
||||||
fileStream,
|
|
||||||
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<Buffer> {
|
|
||||||
try {
|
|
||||||
const archive = archiver('zip', {
|
|
||||||
zlib: {
|
|
||||||
level: options.compressionLevel || 6,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const buffers: Buffer[] = [];
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
archive.on('data', (chunk) => {
|
|
||||||
buffers.push(chunk);
|
|
||||||
});
|
|
||||||
|
|
||||||
archive.on('end', () => {
|
|
||||||
const result = Buffer.concat(buffers);
|
|
||||||
this.logger.log(`ZIP buffer created: ${result.length} bytes`);
|
|
||||||
resolve(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
archive.on('error', (err) => {
|
|
||||||
this.logger.error('Archive error:', err);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add files to archive
|
|
||||||
Promise.all(
|
|
||||||
files.map(file => this.addFileToArchive(archive, file, options))
|
|
||||||
).then(() => {
|
|
||||||
archive.finalize();
|
|
||||||
}).catch(reject);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to create ZIP buffer:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimate ZIP size
|
|
||||||
*/
|
|
||||||
async estimateZipSize(files: ZipFile[], compressionLevel: number = 0): Promise<number> {
|
|
||||||
try {
|
|
||||||
let totalSize = 0;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const fileSize = await this.storageService.getFileSize(file.path);
|
|
||||||
|
|
||||||
// For compression level 0 (store only), size is roughly the same
|
|
||||||
// For higher compression levels, estimate 70-90% of original size for images
|
|
||||||
const compressionRatio = compressionLevel === 0 ? 1.0 : 0.8;
|
|
||||||
totalSize += Math.floor(fileSize * compressionRatio);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to get size for ${file.path}:`, error);
|
|
||||||
// Skip this file in size calculation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add ZIP overhead (roughly 30 bytes per file + central directory)
|
|
||||||
const zipOverhead = files.length * 50;
|
|
||||||
|
|
||||||
return totalSize + zipOverhead;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to estimate ZIP size:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate ZIP contents
|
|
||||||
*/
|
|
||||||
async validateZipContents(files: ZipFile[]): Promise<{
|
|
||||||
valid: boolean;
|
|
||||||
errors: string[];
|
|
||||||
warnings: string[];
|
|
||||||
}> {
|
|
||||||
const errors: string[] = [];
|
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check for empty file list
|
|
||||||
if (files.length === 0) {
|
|
||||||
errors.push('No files to add to ZIP');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for duplicate filenames
|
|
||||||
const filenames = new Set<string>();
|
|
||||||
const duplicates = new Set<string>();
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const sanitizedName = this.sanitizeFilename(file.name);
|
|
||||||
|
|
||||||
if (filenames.has(sanitizedName)) {
|
|
||||||
duplicates.add(sanitizedName);
|
|
||||||
}
|
|
||||||
filenames.add(sanitizedName);
|
|
||||||
|
|
||||||
// Check if file exists in storage
|
|
||||||
try {
|
|
||||||
await this.storageService.fileExists(file.path);
|
|
||||||
} catch (error) {
|
|
||||||
errors.push(`File not found: ${file.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate filename
|
|
||||||
if (!this.isValidFilename(file.name)) {
|
|
||||||
warnings.push(`Invalid filename: ${file.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duplicates.size > 0) {
|
|
||||||
warnings.push(`Duplicate filenames: ${Array.from(duplicates).join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: errors.length === 0,
|
|
||||||
errors,
|
|
||||||
warnings,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to validate ZIP contents:', error);
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
errors: ['Failed to validate ZIP contents'],
|
|
||||||
warnings: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if file is an image
|
|
||||||
*/
|
|
||||||
private isImageFile(filename: string): boolean {
|
|
||||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'];
|
|
||||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
|
||||||
return imageExtensions.includes(ext);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize filename for ZIP archive
|
|
||||||
*/
|
|
||||||
private sanitizeFilename(filename: string): string {
|
|
||||||
// Remove or replace invalid characters
|
|
||||||
let sanitized = filename
|
|
||||||
.replace(/[<>:"/\\|?*]/g, '_') // Replace invalid chars with underscore
|
|
||||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Ensure filename is not empty
|
|
||||||
if (!sanitized) {
|
|
||||||
sanitized = 'unnamed_file';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure filename is not too long (255 char limit for most filesystems)
|
|
||||||
if (sanitized.length > 255) {
|
|
||||||
const ext = sanitized.substring(sanitized.lastIndexOf('.'));
|
|
||||||
const name = sanitized.substring(0, sanitized.lastIndexOf('.'));
|
|
||||||
sanitized = name.substring(0, 255 - ext.length) + ext;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate filename
|
|
||||||
*/
|
|
||||||
private isValidFilename(filename: string): boolean {
|
|
||||||
// Check for empty filename
|
|
||||||
if (!filename || filename.trim().length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for reserved names (Windows)
|
|
||||||
const reservedNames = [
|
|
||||||
'CON', 'PRN', 'AUX', 'NUL',
|
|
||||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
|
||||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
|
|
||||||
];
|
|
||||||
|
|
||||||
const nameWithoutExt = filename.substring(0, filename.lastIndexOf('.') || filename.length);
|
|
||||||
if (reservedNames.includes(nameWithoutExt.toUpperCase())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for invalid characters
|
|
||||||
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
|
|
||||||
if (invalidChars.test(filename)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get optimal compression level for file type
|
|
||||||
*/
|
|
||||||
getOptimalCompressionLevel(filename: string): number {
|
|
||||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
|
||||||
|
|
||||||
// Images are already compressed, so use store only (0) or light compression (1)
|
|
||||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
|
||||||
if (imageExtensions.includes(ext)) {
|
|
||||||
return 0; // Store only for faster processing
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other files, use moderate compression
|
|
||||||
return 6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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}`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,304 +0,0 @@
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Put,
|
|
||||||
Param,
|
|
||||||
Body,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
HttpStatus,
|
|
||||||
BadRequestException,
|
|
||||||
ForbiddenException,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../auth/auth.guard';
|
|
||||||
import { ImagesService } from './images.service';
|
|
||||||
import { UpdateFilenameDto, UpdateFilenameResponseDto } from './dto/update-filename.dto';
|
|
||||||
import { ImageResponseDto, BatchImagesResponseDto } from './dto/image-response.dto';
|
|
||||||
|
|
||||||
@ApiTags('images')
|
|
||||||
@Controller('api/image')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
export class ImagesController {
|
|
||||||
constructor(private readonly imagesService: ImagesService) {}
|
|
||||||
|
|
||||||
@Put(':imageId/filename')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Update image filename',
|
|
||||||
description: 'Updates the proposed filename for a specific image',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Filename updated successfully',
|
|
||||||
type: UpdateFilenameResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.BAD_REQUEST,
|
|
||||||
description: 'Invalid filename or request data',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NOT_FOUND,
|
|
||||||
description: 'Image not found',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.FORBIDDEN,
|
|
||||||
description: 'Not authorized to update this image',
|
|
||||||
})
|
|
||||||
async updateImageFilename(
|
|
||||||
@Param('imageId') imageId: string,
|
|
||||||
@Body() updateFilenameDto: UpdateFilenameDto,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<UpdateFilenameResponseDto> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.imagesService.updateFilename(
|
|
||||||
imageId,
|
|
||||||
userId,
|
|
||||||
updateFilenameDto.new_name
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof BadRequestException ||
|
|
||||||
error instanceof ForbiddenException ||
|
|
||||||
error instanceof NotFoundException
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to update image filename');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':imageId')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get image details',
|
|
||||||
description: 'Returns detailed information about a specific image',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Image details retrieved successfully',
|
|
||||||
type: ImageResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NOT_FOUND,
|
|
||||||
description: 'Image not found',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.FORBIDDEN,
|
|
||||||
description: 'Not authorized to access this image',
|
|
||||||
})
|
|
||||||
async getImage(
|
|
||||||
@Param('imageId') imageId: string,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<ImageResponseDto> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = await this.imagesService.getImage(imageId, userId);
|
|
||||||
return image;
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof BadRequestException ||
|
|
||||||
error instanceof ForbiddenException ||
|
|
||||||
error instanceof NotFoundException
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to get image details');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('batch/:batchId')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get all images in a batch',
|
|
||||||
description: 'Returns all images belonging to a specific batch',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Batch images retrieved successfully',
|
|
||||||
type: BatchImagesResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NOT_FOUND,
|
|
||||||
description: 'Batch not found',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.FORBIDDEN,
|
|
||||||
description: 'Not authorized to access this batch',
|
|
||||||
})
|
|
||||||
async getBatchImages(
|
|
||||||
@Param('batchId') batchId: string,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<BatchImagesResponseDto> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchImages = await this.imagesService.getBatchImages(batchId, userId);
|
|
||||||
return batchImages;
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof BadRequestException ||
|
|
||||||
error instanceof ForbiddenException ||
|
|
||||||
error instanceof NotFoundException
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to get batch images');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':imageId/download')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get image download URL',
|
|
||||||
description: 'Returns a presigned URL for downloading the original or processed image',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Download URL generated successfully',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
download_url: {
|
|
||||||
type: 'string',
|
|
||||||
example: 'https://storage.example.com/images/processed/image.jpg?expires=...',
|
|
||||||
},
|
|
||||||
expires_at: {
|
|
||||||
type: 'string',
|
|
||||||
example: '2024-01-01T13:00:00.000Z',
|
|
||||||
},
|
|
||||||
filename: {
|
|
||||||
type: 'string',
|
|
||||||
example: 'modern-kitchen-renovation.jpg',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NOT_FOUND,
|
|
||||||
description: 'Image not found',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.FORBIDDEN,
|
|
||||||
description: 'Not authorized to download this image',
|
|
||||||
})
|
|
||||||
async getImageDownloadUrl(
|
|
||||||
@Param('imageId') imageId: string,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<{
|
|
||||||
download_url: string;
|
|
||||||
expires_at: string;
|
|
||||||
filename: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadInfo = await this.imagesService.getImageDownloadUrl(imageId, userId);
|
|
||||||
return downloadInfo;
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof BadRequestException ||
|
|
||||||
error instanceof ForbiddenException ||
|
|
||||||
error instanceof NotFoundException
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to generate download URL');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put(':imageId/approve')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Approve proposed filename',
|
|
||||||
description: 'Approves the AI-generated proposed filename as the final filename',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Filename approved successfully',
|
|
||||||
type: UpdateFilenameResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NOT_FOUND,
|
|
||||||
description: 'Image not found',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.BAD_REQUEST,
|
|
||||||
description: 'No proposed filename to approve',
|
|
||||||
})
|
|
||||||
async approveFilename(
|
|
||||||
@Param('imageId') imageId: string,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<UpdateFilenameResponseDto> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.imagesService.approveProposedFilename(imageId, userId);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof BadRequestException ||
|
|
||||||
error instanceof ForbiddenException ||
|
|
||||||
error instanceof NotFoundException
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to approve filename');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put(':imageId/revert')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Revert to original filename',
|
|
||||||
description: 'Reverts the image filename back to the original uploaded filename',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Filename reverted successfully',
|
|
||||||
type: UpdateFilenameResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.NOT_FOUND,
|
|
||||||
description: 'Image not found',
|
|
||||||
})
|
|
||||||
async revertFilename(
|
|
||||||
@Param('imageId') imageId: string,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<UpdateFilenameResponseDto> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.imagesService.revertToOriginalFilename(imageId, userId);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof BadRequestException ||
|
|
||||||
error instanceof ForbiddenException ||
|
|
||||||
error instanceof NotFoundException
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to revert filename');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -1,442 +0,0 @@
|
||||||
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
|
||||||
import { ImageStatus } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../database/prisma.service';
|
|
||||||
import { StorageService } from '../storage/storage.service';
|
|
||||||
import { UpdateFilenameResponseDto } from './dto/update-filename.dto';
|
|
||||||
import { ImageResponseDto, BatchImagesResponseDto } from './dto/image-response.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ImagesService {
|
|
||||||
private readonly logger = new Logger(ImagesService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly storageService: StorageService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update image filename
|
|
||||||
*/
|
|
||||||
async updateFilename(
|
|
||||||
imageId: string,
|
|
||||||
userId: string,
|
|
||||||
newName: string,
|
|
||||||
): Promise<UpdateFilenameResponseDto> {
|
|
||||||
try {
|
|
||||||
// Find image and verify ownership
|
|
||||||
const image = await this.prisma.image.findFirst({
|
|
||||||
where: {
|
|
||||||
id: imageId,
|
|
||||||
batch: { userId },
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
batch: { select: { userId: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!image) {
|
|
||||||
throw new NotFoundException('Image not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate filename
|
|
||||||
if (!this.isValidFilename(newName)) {
|
|
||||||
throw new BadRequestException('Invalid filename format');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure filename has proper extension
|
|
||||||
if (!this.hasValidExtension(newName)) {
|
|
||||||
throw new BadRequestException('Filename must have a valid image extension');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the proposed name
|
|
||||||
const updatedImage = await this.prisma.image.update({
|
|
||||||
where: { id: imageId },
|
|
||||||
data: {
|
|
||||||
proposedName: newName,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Updated filename for image: ${imageId} to: ${newName}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: updatedImage.id,
|
|
||||||
proposed_name: updatedImage.proposedName!,
|
|
||||||
original_name: updatedImage.originalName,
|
|
||||||
updated_at: updatedImage.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof NotFoundException ||
|
|
||||||
error instanceof BadRequestException
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
this.logger.error(`Failed to update filename for image: ${imageId}`, error.stack);
|
|
||||||
throw new BadRequestException('Failed to update image filename');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get image details
|
|
||||||
*/
|
|
||||||
async getImage(imageId: string, userId: string): Promise<ImageResponseDto> {
|
|
||||||
try {
|
|
||||||
const image = await this.prisma.image.findFirst({
|
|
||||||
where: {
|
|
||||||
id: imageId,
|
|
||||||
batch: { userId },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!image) {
|
|
||||||
throw new NotFoundException('Image not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.mapImageToResponse(image);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof NotFoundException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
this.logger.error(`Failed to get image: ${imageId}`, error.stack);
|
|
||||||
throw new BadRequestException('Failed to get image details');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all images in a batch
|
|
||||||
*/
|
|
||||||
async getBatchImages(batchId: string, userId: string): Promise<BatchImagesResponseDto> {
|
|
||||||
try {
|
|
||||||
// Verify batch ownership
|
|
||||||
const batch = await this.prisma.batch.findFirst({
|
|
||||||
where: {
|
|
||||||
id: batchId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
images: {
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!batch) {
|
|
||||||
throw new NotFoundException('Batch not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate status summary
|
|
||||||
const statusSummary = {
|
|
||||||
pending: 0,
|
|
||||||
processing: 0,
|
|
||||||
completed: 0,
|
|
||||||
failed: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
batch.images.forEach((image) => {
|
|
||||||
switch (image.status) {
|
|
||||||
case ImageStatus.PENDING:
|
|
||||||
statusSummary.pending++;
|
|
||||||
break;
|
|
||||||
case ImageStatus.PROCESSING:
|
|
||||||
statusSummary.processing++;
|
|
||||||
break;
|
|
||||||
case ImageStatus.COMPLETED:
|
|
||||||
statusSummary.completed++;
|
|
||||||
break;
|
|
||||||
case ImageStatus.FAILED:
|
|
||||||
statusSummary.failed++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
batch_id: batchId,
|
|
||||||
total_images: batch.images.length,
|
|
||||||
images: batch.images.map(this.mapImageToResponse),
|
|
||||||
status_summary: statusSummary,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof NotFoundException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
this.logger.error(`Failed to get batch images: ${batchId}`, error.stack);
|
|
||||||
throw new BadRequestException('Failed to get batch images');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get presigned download URL for image
|
|
||||||
*/
|
|
||||||
async getImageDownloadUrl(
|
|
||||||
imageId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<{
|
|
||||||
download_url: string;
|
|
||||||
expires_at: string;
|
|
||||||
filename: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const image = await this.prisma.image.findFirst({
|
|
||||||
where: {
|
|
||||||
id: imageId,
|
|
||||||
batch: { userId },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!image) {
|
|
||||||
throw new NotFoundException('Image not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!image.s3Key) {
|
|
||||||
throw new BadRequestException('Image file not available for download');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate presigned URL (expires in 1 hour)
|
|
||||||
const downloadUrl = await this.storageService.getPresignedUrl(image.s3Key, 3600);
|
|
||||||
const expiresAt = new Date(Date.now() + 3600 * 1000);
|
|
||||||
|
|
||||||
// Use final name if available, otherwise proposed name, otherwise original name
|
|
||||||
const filename = image.finalName || image.proposedName || image.originalName;
|
|
||||||
|
|
||||||
this.logger.log(`Generated download URL for image: ${imageId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
download_url: downloadUrl,
|
|
||||||
expires_at: expiresAt.toISOString(),
|
|
||||||
filename,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof NotFoundException ||
|
|
||||||
error instanceof BadRequestException
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
this.logger.error(`Failed to generate download URL for image: ${imageId}`, error.stack);
|
|
||||||
throw new BadRequestException('Failed to generate download URL');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Approve the proposed filename as final
|
|
||||||
*/
|
|
||||||
async approveProposedFilename(
|
|
||||||
imageId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<UpdateFilenameResponseDto> {
|
|
||||||
try {
|
|
||||||
const image = await this.prisma.image.findFirst({
|
|
||||||
where: {
|
|
||||||
id: imageId,
|
|
||||||
batch: { userId },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!image) {
|
|
||||||
throw new NotFoundException('Image not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!image.proposedName) {
|
|
||||||
throw new BadRequestException('No proposed filename to approve');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedImage = await this.prisma.image.update({
|
|
||||||
where: { id: imageId },
|
|
||||||
data: {
|
|
||||||
finalName: image.proposedName,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Approved filename for image: ${imageId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: updatedImage.id,
|
|
||||||
proposed_name: updatedImage.proposedName!,
|
|
||||||
original_name: updatedImage.originalName,
|
|
||||||
updated_at: updatedImage.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof NotFoundException ||
|
|
||||||
error instanceof BadRequestException
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
this.logger.error(`Failed to approve filename for image: ${imageId}`, error.stack);
|
|
||||||
throw new BadRequestException('Failed to approve filename');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revert to original filename
|
|
||||||
*/
|
|
||||||
async revertToOriginalFilename(
|
|
||||||
imageId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<UpdateFilenameResponseDto> {
|
|
||||||
try {
|
|
||||||
const image = await this.prisma.image.findFirst({
|
|
||||||
where: {
|
|
||||||
id: imageId,
|
|
||||||
batch: { userId },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!image) {
|
|
||||||
throw new NotFoundException('Image not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedImage = await this.prisma.image.update({
|
|
||||||
where: { id: imageId },
|
|
||||||
data: {
|
|
||||||
proposedName: image.originalName,
|
|
||||||
finalName: null,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Reverted filename for image: ${imageId} to original`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: updatedImage.id,
|
|
||||||
proposed_name: updatedImage.proposedName!,
|
|
||||||
original_name: updatedImage.originalName,
|
|
||||||
updated_at: updatedImage.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof NotFoundException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
this.logger.error(`Failed to revert filename for image: ${imageId}`, error.stack);
|
|
||||||
throw new BadRequestException('Failed to revert filename');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update image processing status (called by queue processors)
|
|
||||||
*/
|
|
||||||
async updateImageStatus(
|
|
||||||
imageId: string,
|
|
||||||
status: ImageStatus,
|
|
||||||
visionTags?: any,
|
|
||||||
proposedName?: string,
|
|
||||||
error?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const updateData: any = {
|
|
||||||
status,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (visionTags) {
|
|
||||||
updateData.visionTags = visionTags;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (proposedName) {
|
|
||||||
updateData.proposedName = proposedName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
updateData.processingError = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
|
|
||||||
updateData.processedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.image.update({
|
|
||||||
where: { id: imageId },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.debug(`Updated image status: ${imageId} to ${status}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to update image status: ${imageId}`, error.stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get images by status (for queue processing)
|
|
||||||
*/
|
|
||||||
async getImagesByStatus(batchId: string, status: ImageStatus) {
|
|
||||||
try {
|
|
||||||
return await this.prisma.image.findMany({
|
|
||||||
where: {
|
|
||||||
batchId,
|
|
||||||
status,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
originalName: true,
|
|
||||||
s3Key: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get images by status: ${batchId}`, error.stack);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map database image to response DTO
|
|
||||||
*/
|
|
||||||
private mapImageToResponse(image: any): ImageResponseDto {
|
|
||||||
return {
|
|
||||||
id: image.id,
|
|
||||||
batch_id: image.batchId,
|
|
||||||
original_name: image.originalName,
|
|
||||||
proposed_name: image.proposedName,
|
|
||||||
final_name: image.finalName,
|
|
||||||
status: image.status,
|
|
||||||
vision_tags: image.visionTags,
|
|
||||||
file_size: image.fileSize,
|
|
||||||
dimensions: image.dimensions,
|
|
||||||
mime_type: image.mimeType,
|
|
||||||
processing_error: image.processingError,
|
|
||||||
created_at: image.createdAt.toISOString(),
|
|
||||||
updated_at: image.updatedAt.toISOString(),
|
|
||||||
processed_at: image.processedAt?.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate filename format
|
|
||||||
*/
|
|
||||||
private isValidFilename(filename: string): boolean {
|
|
||||||
// Check for invalid characters
|
|
||||||
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
|
|
||||||
if (invalidChars.test(filename)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check length
|
|
||||||
if (filename.length === 0 || filename.length > 255) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for reserved names
|
|
||||||
const reservedNames = [
|
|
||||||
'CON', 'PRN', 'AUX', 'NUL',
|
|
||||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
|
||||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
|
||||||
];
|
|
||||||
|
|
||||||
const nameWithoutExt = filename.split('.')[0].toUpperCase();
|
|
||||||
if (reservedNames.includes(nameWithoutExt)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if filename has valid image extension
|
|
||||||
*/
|
|
||||||
private hasValidExtension(filename: string): boolean {
|
|
||||||
const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'];
|
|
||||||
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
|
||||||
return validExtensions.includes(extension);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
HttpStatus,
|
|
||||||
BadRequestException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../auth/auth.guard';
|
|
||||||
import { KeywordsService } from './keywords.service';
|
|
||||||
import { EnhanceKeywordsDto, EnhanceKeywordsResponseDto } from './dto/enhance-keywords.dto';
|
|
||||||
|
|
||||||
@ApiTags('keywords')
|
|
||||||
@Controller('api/keywords')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
export class KeywordsController {
|
|
||||||
constructor(private readonly keywordsService: KeywordsService) {}
|
|
||||||
|
|
||||||
@Post('enhance')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Enhance keywords with AI suggestions',
|
|
||||||
description: 'Takes user-provided keywords and returns AI-enhanced SEO-optimized keywords and suggestions',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Keywords enhanced successfully',
|
|
||||||
type: EnhanceKeywordsResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.BAD_REQUEST,
|
|
||||||
description: 'Invalid keywords or request data',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.TOO_MANY_REQUESTS,
|
|
||||||
description: 'Rate limit exceeded for keyword enhancement',
|
|
||||||
})
|
|
||||||
async enhanceKeywords(
|
|
||||||
@Body() enhanceKeywordsDto: EnhanceKeywordsDto,
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<EnhanceKeywordsResponseDto> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check rate limits
|
|
||||||
await this.keywordsService.checkRateLimit(userId);
|
|
||||||
|
|
||||||
// Enhance keywords with AI
|
|
||||||
const enhancedResult = await this.keywordsService.enhanceKeywords(
|
|
||||||
enhanceKeywordsDto.keywords,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return enhancedResult;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof BadRequestException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to enhance keywords');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('suggest')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get keyword suggestions for image context',
|
|
||||||
description: 'Provides keyword suggestions based on image analysis context',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Keyword suggestions generated successfully',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
suggestions: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string' },
|
|
||||||
example: ['interior-design', 'home-decor', 'modern-style', 'contemporary'],
|
|
||||||
},
|
|
||||||
categories: {
|
|
||||||
type: 'object',
|
|
||||||
example: {
|
|
||||||
style: ['modern', 'contemporary', 'minimalist'],
|
|
||||||
room: ['kitchen', 'living-room', 'bedroom'],
|
|
||||||
color: ['white', 'black', 'gray'],
|
|
||||||
material: ['wood', 'metal', 'glass'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
async getKeywordSuggestions(
|
|
||||||
@Body() body: { context?: string; category?: string },
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<{
|
|
||||||
suggestions: string[];
|
|
||||||
categories: Record<string, string[]>;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
const suggestions = await this.keywordsService.getKeywordSuggestions(
|
|
||||||
body.context,
|
|
||||||
body.category,
|
|
||||||
);
|
|
||||||
|
|
||||||
return suggestions;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof BadRequestException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to get keyword suggestions');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('validate')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Validate keywords for SEO optimization',
|
|
||||||
description: 'Checks keywords for SEO best practices and provides recommendations',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'Keywords validated successfully',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
valid_keywords: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string' },
|
|
||||||
example: ['modern-kitchen', 'contemporary-design'],
|
|
||||||
},
|
|
||||||
invalid_keywords: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
keyword: { type: 'string' },
|
|
||||||
reason: { type: 'string' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
example: [
|
|
||||||
{ keyword: 'a', reason: 'Too short for SEO value' },
|
|
||||||
{ keyword: 'the-best-kitchen-in-the-world-ever', reason: 'Too long for practical use' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
recommendations: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string' },
|
|
||||||
example: [
|
|
||||||
'Use hyphens instead of spaces',
|
|
||||||
'Keep keywords between 2-4 words',
|
|
||||||
'Avoid stop words like "the", "and", "or"',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
async validateKeywords(
|
|
||||||
@Body() body: { keywords: string[] },
|
|
||||||
@Request() req: any,
|
|
||||||
): Promise<{
|
|
||||||
valid_keywords: string[];
|
|
||||||
invalid_keywords: Array<{ keyword: string; reason: string }>;
|
|
||||||
recommendations: string[];
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body.keywords || !Array.isArray(body.keywords)) {
|
|
||||||
throw new BadRequestException('Keywords array is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const validation = await this.keywordsService.validateKeywords(body.keywords);
|
|
||||||
return validation;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof BadRequestException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new BadRequestException('Failed to validate keywords');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -1,345 +0,0 @@
|
||||||
import { Injectable, Logger, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { EnhanceKeywordsResponseDto } from './dto/enhance-keywords.dto';
|
|
||||||
// import OpenAI from 'openai'; // Uncomment when ready to use actual OpenAI integration
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class KeywordsService {
|
|
||||||
private readonly logger = new Logger(KeywordsService.name);
|
|
||||||
// private readonly openai: OpenAI; // Uncomment when ready to use actual OpenAI
|
|
||||||
private readonly rateLimitMap = new Map<string, { count: number; resetTime: number }>();
|
|
||||||
private readonly RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
|
||||||
private readonly RATE_LIMIT_MAX_REQUESTS = 10; // 10 requests per minute per user
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
// Initialize OpenAI client when ready
|
|
||||||
// this.openai = new OpenAI({
|
|
||||||
// apiKey: this.configService.get<string>('OPENAI_API_KEY'),
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enhance keywords with AI suggestions
|
|
||||||
*/
|
|
||||||
async enhanceKeywords(
|
|
||||||
keywords: string[],
|
|
||||||
userId: string,
|
|
||||||
): Promise<EnhanceKeywordsResponseDto> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.logger.log(`Enhancing keywords for user: ${userId}`);
|
|
||||||
|
|
||||||
// Clean and normalize input keywords
|
|
||||||
const cleanKeywords = this.cleanKeywords(keywords);
|
|
||||||
|
|
||||||
// Generate enhanced keywords using AI
|
|
||||||
const enhancedKeywords = await this.generateEnhancedKeywords(cleanKeywords);
|
|
||||||
const relatedKeywords = await this.generateRelatedKeywords(cleanKeywords);
|
|
||||||
const longTailKeywords = await this.generateLongTailKeywords(cleanKeywords);
|
|
||||||
|
|
||||||
const processingTime = (Date.now() - startTime) / 1000;
|
|
||||||
|
|
||||||
const result: EnhanceKeywordsResponseDto = {
|
|
||||||
original_keywords: cleanKeywords,
|
|
||||||
enhanced_keywords: enhancedKeywords,
|
|
||||||
related_keywords: relatedKeywords,
|
|
||||||
long_tail_keywords: longTailKeywords,
|
|
||||||
metadata: {
|
|
||||||
processing_time: processingTime,
|
|
||||||
ai_model: 'mock-gpt-4', // Replace with actual model when using OpenAI
|
|
||||||
confidence_score: 0.92,
|
|
||||||
keywords_generated: enhancedKeywords.length + relatedKeywords.length + longTailKeywords.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.log(`Enhanced keywords successfully for user: ${userId}`);
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to enhance keywords for user: ${userId}`, error.stack);
|
|
||||||
throw new BadRequestException('Failed to enhance keywords');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get keyword suggestions based on context
|
|
||||||
*/
|
|
||||||
async getKeywordSuggestions(
|
|
||||||
context?: string,
|
|
||||||
category?: string,
|
|
||||||
): Promise<{
|
|
||||||
suggestions: string[];
|
|
||||||
categories: Record<string, string[]>;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
// Mock suggestions - replace with actual AI generation
|
|
||||||
const baseSuggestions = [
|
|
||||||
'interior-design',
|
|
||||||
'home-decor',
|
|
||||||
'modern-style',
|
|
||||||
'contemporary',
|
|
||||||
'minimalist',
|
|
||||||
'elegant',
|
|
||||||
'stylish',
|
|
||||||
'trendy',
|
|
||||||
];
|
|
||||||
|
|
||||||
const categories = {
|
|
||||||
style: ['modern', 'contemporary', 'minimalist', 'industrial', 'scandinavian', 'rustic'],
|
|
||||||
room: ['kitchen', 'living-room', 'bedroom', 'bathroom', 'office', 'dining-room'],
|
|
||||||
color: ['white', 'black', 'gray', 'blue', 'green', 'brown'],
|
|
||||||
material: ['wood', 'metal', 'glass', 'stone', 'fabric', 'leather'],
|
|
||||||
feature: ['island', 'cabinet', 'counter', 'lighting', 'flooring', 'window'],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter suggestions based on context or category
|
|
||||||
let suggestions = baseSuggestions;
|
|
||||||
if (category && categories[category]) {
|
|
||||||
suggestions = [...baseSuggestions, ...categories[category]];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
suggestions: suggestions.slice(0, 12), // Limit to 12 suggestions
|
|
||||||
categories,
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get keyword suggestions', error.stack);
|
|
||||||
throw new BadRequestException('Failed to get keyword suggestions');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate keywords for SEO optimization
|
|
||||||
*/
|
|
||||||
async validateKeywords(keywords: string[]): Promise<{
|
|
||||||
valid_keywords: string[];
|
|
||||||
invalid_keywords: Array<{ keyword: string; reason: string }>;
|
|
||||||
recommendations: string[];
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const validKeywords: string[] = [];
|
|
||||||
const invalidKeywords: Array<{ keyword: string; reason: string }> = [];
|
|
||||||
const recommendations: string[] = [];
|
|
||||||
|
|
||||||
for (const keyword of keywords) {
|
|
||||||
const validation = this.validateSingleKeyword(keyword);
|
|
||||||
if (validation.isValid) {
|
|
||||||
validKeywords.push(keyword);
|
|
||||||
} else {
|
|
||||||
invalidKeywords.push({
|
|
||||||
keyword,
|
|
||||||
reason: validation.reason,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate recommendations
|
|
||||||
if (invalidKeywords.some(item => item.reason.includes('spaces'))) {
|
|
||||||
recommendations.push('Use hyphens instead of spaces for better SEO');
|
|
||||||
}
|
|
||||||
if (invalidKeywords.some(item => item.reason.includes('short'))) {
|
|
||||||
recommendations.push('Keywords should be at least 2 characters long');
|
|
||||||
}
|
|
||||||
if (invalidKeywords.some(item => item.reason.includes('long'))) {
|
|
||||||
recommendations.push('Keep keywords concise, ideally 2-4 words');
|
|
||||||
}
|
|
||||||
if (keywords.some(k => /\b(the|and|or|but|in|on|at|to|for|of|with|by)\b/i.test(k))) {
|
|
||||||
recommendations.push('Avoid stop words like "the", "and", "or" for better SEO');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid_keywords: validKeywords,
|
|
||||||
invalid_keywords: invalidKeywords,
|
|
||||||
recommendations,
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to validate keywords', error.stack);
|
|
||||||
throw new BadRequestException('Failed to validate keywords');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check rate limit for user
|
|
||||||
*/
|
|
||||||
async checkRateLimit(userId: string): Promise<void> {
|
|
||||||
const now = Date.now();
|
|
||||||
const userLimit = this.rateLimitMap.get(userId);
|
|
||||||
|
|
||||||
if (!userLimit || now > userLimit.resetTime) {
|
|
||||||
// Reset or create new limit window
|
|
||||||
this.rateLimitMap.set(userId, {
|
|
||||||
count: 1,
|
|
||||||
resetTime: now + this.RATE_LIMIT_WINDOW,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userLimit.count >= this.RATE_LIMIT_MAX_REQUESTS) {
|
|
||||||
throw new HttpException(
|
|
||||||
'Rate limit exceeded. Try again later.',
|
|
||||||
HttpStatus.TOO_MANY_REQUESTS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
userLimit.count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean and normalize keywords
|
|
||||||
*/
|
|
||||||
private cleanKeywords(keywords: string[]): string[] {
|
|
||||||
return keywords
|
|
||||||
.map(keyword => keyword.trim().toLowerCase())
|
|
||||||
.filter(keyword => keyword.length > 0)
|
|
||||||
.filter((keyword, index, arr) => arr.indexOf(keyword) === index); // Remove duplicates
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate enhanced keywords using AI (mock implementation)
|
|
||||||
*/
|
|
||||||
private async generateEnhancedKeywords(keywords: string[]): Promise<string[]> {
|
|
||||||
// Simulate AI processing time
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
// Mock enhanced keywords - replace with actual AI generation
|
|
||||||
const enhancementPrefixes = ['modern', 'contemporary', 'sleek', 'stylish', 'elegant', 'trendy'];
|
|
||||||
const enhancementSuffixes = ['design', 'style', 'decor', 'interior', 'renovation', 'makeover'];
|
|
||||||
|
|
||||||
const enhanced: string[] = [];
|
|
||||||
|
|
||||||
for (const keyword of keywords) {
|
|
||||||
// Create variations with prefixes and suffixes
|
|
||||||
enhancementPrefixes.forEach(prefix => {
|
|
||||||
if (!keyword.startsWith(prefix)) {
|
|
||||||
enhanced.push(`${prefix}-${keyword}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
enhancementSuffixes.forEach(suffix => {
|
|
||||||
if (!keyword.endsWith(suffix)) {
|
|
||||||
enhanced.push(`${keyword}-${suffix}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create compound keywords
|
|
||||||
if (keywords.length > 1) {
|
|
||||||
keywords.forEach(otherKeyword => {
|
|
||||||
if (keyword !== otherKeyword) {
|
|
||||||
enhanced.push(`${keyword}-${otherKeyword}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove duplicates and limit results
|
|
||||||
return [...new Set(enhanced)].slice(0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate related keywords (mock implementation)
|
|
||||||
*/
|
|
||||||
private async generateRelatedKeywords(keywords: string[]): Promise<string[]> {
|
|
||||||
// Simulate AI processing time
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
|
|
||||||
// Mock related keywords - replace with actual AI generation
|
|
||||||
const relatedMap: Record<string, string[]> = {
|
|
||||||
kitchen: ['culinary-space', 'cooking-area', 'kitchen-cabinets', 'kitchen-appliances', 'kitchen-island'],
|
|
||||||
modern: ['contemporary', 'minimalist', 'sleek', 'current', 'updated'],
|
|
||||||
renovation: ['remodel', 'makeover', 'upgrade', 'transformation', 'improvement'],
|
|
||||||
design: ['decor', 'style', 'interior', 'aesthetic', 'layout'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const related: string[] = [];
|
|
||||||
keywords.forEach(keyword => {
|
|
||||||
if (relatedMap[keyword]) {
|
|
||||||
related.push(...relatedMap[keyword]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add generic related terms
|
|
||||||
const genericRelated = [
|
|
||||||
'home-improvement',
|
|
||||||
'interior-design',
|
|
||||||
'space-optimization',
|
|
||||||
'aesthetic-enhancement',
|
|
||||||
];
|
|
||||||
|
|
||||||
return [...new Set([...related, ...genericRelated])].slice(0, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate long-tail keywords (mock implementation)
|
|
||||||
*/
|
|
||||||
private async generateLongTailKeywords(keywords: string[]): Promise<string[]> {
|
|
||||||
// Simulate AI processing time
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 400));
|
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const longTailTemplates = [
|
|
||||||
`{keyword}-ideas-${currentYear}`,
|
|
||||||
`{keyword}-trends-${currentYear}`,
|
|
||||||
`{keyword}-inspiration-gallery`,
|
|
||||||
`best-{keyword}-designs`,
|
|
||||||
`{keyword}-before-and-after`,
|
|
||||||
`affordable-{keyword}-solutions`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const longTail: string[] = [];
|
|
||||||
keywords.forEach(keyword => {
|
|
||||||
longTailTemplates.forEach(template => {
|
|
||||||
longTail.push(template.replace('{keyword}', keyword));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create compound long-tail keywords
|
|
||||||
if (keywords.length >= 2) {
|
|
||||||
const compound = keywords.slice(0, 2).join('-');
|
|
||||||
longTail.push(`${compound}-design-ideas-${currentYear}`);
|
|
||||||
longTail.push(`${compound}-renovation-guide`);
|
|
||||||
longTail.push(`${compound}-style-trends`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...new Set(longTail)].slice(0, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a single keyword
|
|
||||||
*/
|
|
||||||
private validateSingleKeyword(keyword: string): { isValid: boolean; reason: string } {
|
|
||||||
// Check length
|
|
||||||
if (keyword.length < 2) {
|
|
||||||
return { isValid: false, reason: 'Too short for SEO value' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyword.length > 60) {
|
|
||||||
return { isValid: false, reason: 'Too long for practical use' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for spaces (should use hyphens)
|
|
||||||
if (keyword.includes(' ')) {
|
|
||||||
return { isValid: false, reason: 'Use hyphens instead of spaces' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for invalid characters
|
|
||||||
if (!/^[a-zA-Z0-9-_]+$/.test(keyword)) {
|
|
||||||
return { isValid: false, reason: 'Contains invalid characters' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for double hyphens or underscores
|
|
||||||
if (keyword.includes('--') || keyword.includes('__')) {
|
|
||||||
return { isValid: false, reason: 'Avoid double hyphens or underscores' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if starts or ends with hyphen/underscore
|
|
||||||
if (keyword.startsWith('-') || keyword.endsWith('-') ||
|
|
||||||
keyword.startsWith('_') || keyword.endsWith('_')) {
|
|
||||||
return { isValid: false, reason: 'Should not start or end with hyphen or underscore' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true, reason: '' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { NestFactory } from '@nestjs/core';
|
|
||||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import helmet from 'helmet';
|
|
||||||
import * as compression from 'compression';
|
|
||||||
import * as cookieParser from 'cookie-parser';
|
|
||||||
|
|
||||||
import { AppModule } from './app.module';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
const logger = new Logger('Bootstrap');
|
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule);
|
|
||||||
const configService = app.get(ConfigService);
|
|
||||||
|
|
||||||
// Global prefix for API routes
|
|
||||||
app.setGlobalPrefix('api');
|
|
||||||
|
|
||||||
// Enable CORS
|
|
||||||
app.enableCors({
|
|
||||||
origin: configService.get<string>('CORS_ORIGIN', 'http://localhost:3000'),
|
|
||||||
credentials: true,
|
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
||||||
allowedHeaders: [
|
|
||||||
'Content-Type',
|
|
||||||
'Authorization',
|
|
||||||
'X-Requested-With',
|
|
||||||
'X-CSRF-Token',
|
|
||||||
'Accept',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Security middleware
|
|
||||||
app.use(helmet({
|
|
||||||
contentSecurityPolicy: false, // We handle CSP in our custom middleware
|
|
||||||
crossOriginEmbedderPolicy: false, // Allow embedding for OAuth
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Compression middleware
|
|
||||||
app.use(compression());
|
|
||||||
|
|
||||||
// Cookie parser
|
|
||||||
app.use(cookieParser(configService.get<string>('COOKIE_SECRET')));
|
|
||||||
|
|
||||||
// Global validation pipe
|
|
||||||
app.useGlobalPipes(
|
|
||||||
new ValidationPipe({
|
|
||||||
whitelist: true, // Strip unknown properties
|
|
||||||
forbidNonWhitelisted: true, // Throw error for unknown properties
|
|
||||||
transform: true, // Transform payloads to DTO instances
|
|
||||||
disableErrorMessages: process.env.NODE_ENV === 'production',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Swagger documentation (development only)
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
const config = new DocumentBuilder()
|
|
||||||
.setTitle('SEO Image Renamer API')
|
|
||||||
.setDescription('AI-powered bulk image renaming SaaS API')
|
|
||||||
.setVersion('1.0')
|
|
||||||
.addBearerAuth(
|
|
||||||
{
|
|
||||||
type: 'http',
|
|
||||||
scheme: 'bearer',
|
|
||||||
bearerFormat: 'JWT',
|
|
||||||
name: 'JWT',
|
|
||||||
description: 'Enter JWT token',
|
|
||||||
in: 'header',
|
|
||||||
},
|
|
||||||
'JWT-auth',
|
|
||||||
)
|
|
||||||
.addTag('Authentication', 'Google OAuth and JWT authentication')
|
|
||||||
.addTag('Users', 'User management and profile operations')
|
|
||||||
.addTag('Batches', 'Image batch processing')
|
|
||||||
.addTag('Images', 'Individual image operations')
|
|
||||||
.addTag('Payments', 'Stripe payment processing')
|
|
||||||
.build();
|
|
||||||
|
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
|
||||||
SwaggerModule.setup('api/docs', app, document, {
|
|
||||||
customSiteTitle: 'SEO Image Renamer API Documentation',
|
|
||||||
customfavIcon: '/favicon.ico',
|
|
||||||
customCss: '.swagger-ui .topbar { display: none }',
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log('Swagger documentation available at /api/docs');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
const port = configService.get<number>('PORT', 3001);
|
|
||||||
await app.listen(port);
|
|
||||||
|
|
||||||
logger.log(`🚀 SEO Image Renamer API running on port ${port}`);
|
|
||||||
logger.log(`📚 Environment: ${process.env.NODE_ENV || 'development'}`);
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
logger.log(`📖 API Documentation: http://localhost:${port}/api/docs`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bootstrap().catch((error) => {
|
|
||||||
Logger.error('Failed to start application', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<string>;
|
|
||||||
private readonly httpRequestDuration: Histogram<string>;
|
|
||||||
|
|
||||||
// Business metrics
|
|
||||||
private readonly imagesProcessedTotal: Counter<string>;
|
|
||||||
private readonly batchesCreatedTotal: Counter<string>;
|
|
||||||
private readonly downloadsTotal: Counter<string>;
|
|
||||||
private readonly paymentsTotal: Counter<string>;
|
|
||||||
private readonly usersRegisteredTotal: Counter<string>;
|
|
||||||
|
|
||||||
// System metrics
|
|
||||||
private readonly activeConnections: Gauge<string>;
|
|
||||||
private readonly queueSize: Gauge<string>;
|
|
||||||
private readonly processingTime: Histogram<string>;
|
|
||||||
private readonly errorRate: Counter<string>;
|
|
||||||
|
|
||||||
// Resource metrics
|
|
||||||
private readonly memoryUsage: Gauge<string>;
|
|
||||||
private readonly cpuUsage: Gauge<string>;
|
|
||||||
private readonly diskUsage: Gauge<string>;
|
|
||||||
|
|
||||||
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<string> {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,297 +0,0 @@
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Get,
|
|
||||||
Body,
|
|
||||||
Param,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
RawBodyRequest,
|
|
||||||
Req,
|
|
||||||
Headers,
|
|
||||||
HttpStatus,
|
|
||||||
HttpException,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../auth/auth.guard';
|
|
||||||
import { PaymentsService } from './payments.service';
|
|
||||||
import { StripeService } from './services/stripe.service';
|
|
||||||
import { WebhookService } from './services/webhook.service';
|
|
||||||
import { CreateCheckoutSessionDto } from './dto/create-checkout-session.dto';
|
|
||||||
import { CreatePortalSessionDto } from './dto/create-portal-session.dto';
|
|
||||||
import { Plan } from '@prisma/client';
|
|
||||||
|
|
||||||
@ApiTags('payments')
|
|
||||||
@Controller('payments')
|
|
||||||
export class PaymentsController {
|
|
||||||
private readonly logger = new Logger(PaymentsController.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly paymentsService: PaymentsService,
|
|
||||||
private readonly stripeService: StripeService,
|
|
||||||
private readonly webhookService: WebhookService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post('checkout')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Create Stripe checkout session' })
|
|
||||||
@ApiResponse({ status: 201, description: 'Checkout session created successfully' })
|
|
||||||
async createCheckoutSession(
|
|
||||||
@Request() req: any,
|
|
||||||
@Body() createCheckoutSessionDto: CreateCheckoutSessionDto,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
const session = await this.stripeService.createCheckoutSession(
|
|
||||||
userId,
|
|
||||||
createCheckoutSessionDto.plan,
|
|
||||||
createCheckoutSessionDto.successUrl,
|
|
||||||
createCheckoutSessionDto.cancelUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: session.id,
|
|
||||||
url: session.url,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to create checkout session:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Failed to create checkout session',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('portal')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Create Stripe customer portal session' })
|
|
||||||
@ApiResponse({ status: 201, description: 'Portal session created successfully' })
|
|
||||||
async createPortalSession(
|
|
||||||
@Request() req: any,
|
|
||||||
@Body() createPortalSessionDto: CreatePortalSessionDto,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
const session = await this.stripeService.createPortalSession(
|
|
||||||
userId,
|
|
||||||
createPortalSessionDto.returnUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: session.url,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to create portal session:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Failed to create portal session',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('subscription')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Get user subscription details' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Subscription details retrieved successfully' })
|
|
||||||
async getSubscription(@Request() req: any) {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
const subscription = await this.paymentsService.getUserSubscription(userId);
|
|
||||||
return subscription;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get subscription:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Failed to get subscription details',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('plans')
|
|
||||||
@ApiOperation({ summary: 'Get available subscription plans' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Plans retrieved successfully' })
|
|
||||||
async getPlans() {
|
|
||||||
return {
|
|
||||||
plans: [
|
|
||||||
{
|
|
||||||
id: Plan.BASIC,
|
|
||||||
name: 'Basic',
|
|
||||||
price: 0,
|
|
||||||
currency: 'usd',
|
|
||||||
interval: 'month',
|
|
||||||
features: [
|
|
||||||
'50 images per month',
|
|
||||||
'AI-powered naming',
|
|
||||||
'Keyword enhancement',
|
|
||||||
'ZIP download',
|
|
||||||
],
|
|
||||||
quotaLimit: 50,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: Plan.PRO,
|
|
||||||
name: 'Pro',
|
|
||||||
price: 900, // $9.00 in cents
|
|
||||||
currency: 'usd',
|
|
||||||
interval: 'month',
|
|
||||||
features: [
|
|
||||||
'500 images per month',
|
|
||||||
'AI-powered naming',
|
|
||||||
'Keyword enhancement',
|
|
||||||
'ZIP download',
|
|
||||||
'Priority support',
|
|
||||||
],
|
|
||||||
quotaLimit: 500,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: Plan.MAX,
|
|
||||||
name: 'Max',
|
|
||||||
price: 1900, // $19.00 in cents
|
|
||||||
currency: 'usd',
|
|
||||||
interval: 'month',
|
|
||||||
features: [
|
|
||||||
'1000 images per month',
|
|
||||||
'AI-powered naming',
|
|
||||||
'Keyword enhancement',
|
|
||||||
'ZIP download',
|
|
||||||
'Priority support',
|
|
||||||
'Advanced analytics',
|
|
||||||
],
|
|
||||||
quotaLimit: 1000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('cancel-subscription')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Cancel user subscription' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Subscription cancelled successfully' })
|
|
||||||
async cancelSubscription(@Request() req: any) {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
await this.paymentsService.cancelSubscription(userId);
|
|
||||||
return { message: 'Subscription cancelled successfully' };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to cancel subscription:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Failed to cancel subscription',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('reactivate-subscription')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Reactivate cancelled subscription' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Subscription reactivated successfully' })
|
|
||||||
async reactivateSubscription(@Request() req: any) {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
await this.paymentsService.reactivateSubscription(userId);
|
|
||||||
return { message: 'Subscription reactivated successfully' };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to reactivate subscription:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Failed to reactivate subscription',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('payment-history')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Get user payment history' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Payment history retrieved successfully' })
|
|
||||||
async getPaymentHistory(@Request() req: any) {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
const payments = await this.paymentsService.getPaymentHistory(userId);
|
|
||||||
return { payments };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get payment history:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Failed to get payment history',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('webhook')
|
|
||||||
@ApiOperation({ summary: 'Handle Stripe webhooks' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Webhook processed successfully' })
|
|
||||||
async handleWebhook(
|
|
||||||
@Req() req: RawBodyRequest<Request>,
|
|
||||||
@Headers('stripe-signature') signature: string,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await this.webhookService.handleWebhook(req.rawBody, signature);
|
|
||||||
return { received: true };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Webhook processing failed:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Webhook processing failed',
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('upgrade')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Upgrade subscription plan' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Plan upgraded successfully' })
|
|
||||||
async upgradePlan(
|
|
||||||
@Request() req: any,
|
|
||||||
@Body() body: { plan: Plan; successUrl: string; cancelUrl: string },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
const session = await this.paymentsService.upgradePlan(
|
|
||||||
userId,
|
|
||||||
body.plan,
|
|
||||||
body.successUrl,
|
|
||||||
body.cancelUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: session.id,
|
|
||||||
url: session.url,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to upgrade plan:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Failed to upgrade plan',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('downgrade')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Downgrade subscription plan' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Plan downgraded successfully' })
|
|
||||||
async downgradePlan(
|
|
||||||
@Request() req: any,
|
|
||||||
@Body() body: { plan: Plan },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
await this.paymentsService.downgradePlan(userId, body.plan);
|
|
||||||
return { message: 'Plan downgraded successfully' };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to downgrade plan:', error);
|
|
||||||
throw new HttpException(
|
|
||||||
'Failed to downgrade plan',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<StripeService>;
|
|
||||||
let subscriptionService: jest.Mocked<SubscriptionService>;
|
|
||||||
let paymentRepository: jest.Mocked<PaymentRepository>;
|
|
||||||
let userRepository: jest.Mocked<UserRepository>;
|
|
||||||
|
|
||||||
const mockUser = {
|
|
||||||
id: 'user-123',
|
|
||||||
email: 'test@example.com',
|
|
||||||
plan: Plan.BASIC,
|
|
||||||
quotaRemaining: 50,
|
|
||||||
quotaResetDate: new Date(),
|
|
||||||
isActive: true,
|
|
||||||
stripeCustomerId: 'cus_123',
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockSubscription = {
|
|
||||||
id: 'sub-123',
|
|
||||||
userId: 'user-123',
|
|
||||||
stripeSubscriptionId: 'sub_stripe_123',
|
|
||||||
stripeCustomerId: 'cus_123',
|
|
||||||
stripePriceId: 'price_123',
|
|
||||||
status: 'ACTIVE',
|
|
||||||
plan: Plan.PRO,
|
|
||||||
currentPeriodStart: new Date(),
|
|
||||||
currentPeriodEnd: new Date(),
|
|
||||||
cancelAtPeriodEnd: false,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
PaymentsService,
|
|
||||||
{
|
|
||||||
provide: StripeService,
|
|
||||||
useValue: {
|
|
||||||
createCheckoutSession: jest.fn(),
|
|
||||||
cancelSubscription: jest.fn(),
|
|
||||||
reactivateSubscription: jest.fn(),
|
|
||||||
scheduleSubscriptionChange: jest.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: SubscriptionService,
|
|
||||||
useValue: {
|
|
||||||
getActiveSubscription: jest.fn(),
|
|
||||||
getCancelledSubscription: jest.fn(),
|
|
||||||
markAsCancelled: jest.fn(),
|
|
||||||
markAsActive: jest.fn(),
|
|
||||||
create: jest.fn(),
|
|
||||||
update: jest.fn(),
|
|
||||||
findByStripeId: jest.fn(),
|
|
||||||
markAsDeleted: jest.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PaymentRepository,
|
|
||||||
useValue: {
|
|
||||||
findByUserId: jest.fn(),
|
|
||||||
create: jest.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: UserRepository,
|
|
||||||
useValue: {
|
|
||||||
findById: jest.fn(),
|
|
||||||
findByStripeCustomerId: jest.fn(),
|
|
||||||
updatePlan: jest.fn(),
|
|
||||||
resetQuota: jest.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<PaymentsService>(PaymentsService);
|
|
||||||
stripeService = module.get(StripeService);
|
|
||||||
subscriptionService = module.get(SubscriptionService);
|
|
||||||
paymentRepository = module.get(PaymentRepository);
|
|
||||||
userRepository = module.get(UserRepository);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getUserSubscription', () => {
|
|
||||||
it('should return user subscription details', async () => {
|
|
||||||
userRepository.findById.mockResolvedValue(mockUser);
|
|
||||||
subscriptionService.getActiveSubscription.mockResolvedValue(mockSubscription);
|
|
||||||
paymentRepository.findByUserId.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await service.getUserSubscription('user-123');
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
currentPlan: Plan.BASIC,
|
|
||||||
quotaRemaining: 50,
|
|
||||||
quotaLimit: 50,
|
|
||||||
quotaResetDate: mockUser.quotaResetDate,
|
|
||||||
subscription: {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
try {
|
|
||||||
const user = await this.userRepository.findById(userId);
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate downgrade path
|
|
||||||
if (!this.isValidDowngrade(user.plan, newPlan)) {
|
|
||||||
throw new Error('Invalid downgrade path');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<void> {
|
|
||||||
try {
|
|
||||||
const user = await this.userRepository.findByStripeCustomerId(stripeCustomerId);
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException('User not found for Stripe customer');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record payment
|
|
||||||
await this.paymentRepository.create({
|
|
||||||
userId: user.id,
|
|
||||||
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<void> {
|
|
||||||
try {
|
|
||||||
const user = await this.userRepository.findByStripeCustomerId(stripeCustomerId);
|
|
||||||
if (!user) {
|
|
||||||
this.logger.warn(`User not found for failed payment: ${stripeCustomerId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record failed payment
|
|
||||||
await this.paymentRepository.create({
|
|
||||||
userId: user.id,
|
|
||||||
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<void> {
|
|
||||||
try {
|
|
||||||
const user = await this.userRepository.findByStripeCustomerId(stripeSubscription.customer);
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException('User not found for subscription');
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = this.getplanFromStripePrice(stripeSubscription.items.data[0].price.id);
|
|
||||||
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<string, Plan> = {
|
|
||||||
'price_pro_monthly': Plan.PRO,
|
|
||||||
'price_max_monthly': Plan.MAX,
|
|
||||||
};
|
|
||||||
|
|
||||||
return priceToplanMap[priceId] || Plan.BASIC;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import Stripe from 'stripe';
|
|
||||||
import { Plan } from '@prisma/client';
|
|
||||||
import { UserRepository } from '../../database/repositories/user.repository';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class StripeService {
|
|
||||||
private readonly logger = new Logger(StripeService.name);
|
|
||||||
private readonly stripe: Stripe;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
private readonly userRepository: UserRepository,
|
|
||||||
) {
|
|
||||||
const apiKey = this.configService.get<string>('STRIPE_SECRET_KEY');
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error('STRIPE_SECRET_KEY is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.stripe = new Stripe(apiKey, {
|
|
||||||
apiVersion: '2023-10-16',
|
|
||||||
typescript: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create checkout session for subscription
|
|
||||||
*/
|
|
||||||
async createCheckoutSession(
|
|
||||||
userId: string,
|
|
||||||
plan: Plan,
|
|
||||||
successUrl: string,
|
|
||||||
cancelUrl: string,
|
|
||||||
isUpgrade: boolean = false,
|
|
||||||
): Promise<Stripe.Checkout.Session> {
|
|
||||||
try {
|
|
||||||
const user = await this.userRepository.findById(userId);
|
|
||||||
if (!user) {
|
|
||||||
throw new Error('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create Stripe customer
|
|
||||||
let customerId = user.stripeCustomerId;
|
|
||||||
if (!customerId) {
|
|
||||||
const customer = await this.stripe.customers.create({
|
|
||||||
email: user.email,
|
|
||||||
metadata: {
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
customerId = customer.id;
|
|
||||||
await this.userRepository.updateStripeCustomerId(userId, customerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get price ID for plan
|
|
||||||
const priceId = this.getPriceIdForPlan(plan);
|
|
||||||
if (!priceId) {
|
|
||||||
throw new Error(`No price configured for plan: ${plan}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionParams: Stripe.Checkout.SessionCreateParams = {
|
|
||||||
customer: customerId,
|
|
||||||
payment_method_types: ['card'],
|
|
||||||
mode: 'subscription',
|
|
||||||
line_items: [
|
|
||||||
{
|
|
||||||
price: priceId,
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
success_url: successUrl,
|
|
||||||
cancel_url: cancelUrl,
|
|
||||||
allow_promotion_codes: true,
|
|
||||||
billing_address_collection: 'required',
|
|
||||||
metadata: {
|
|
||||||
userId,
|
|
||||||
plan,
|
|
||||||
isUpgrade: isUpgrade.toString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// For upgrades, prorate immediately
|
|
||||||
if (isUpgrade) {
|
|
||||||
sessionParams.subscription_data = {
|
|
||||||
proration_behavior: '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<Stripe.BillingPortal.Session> {
|
|
||||||
try {
|
|
||||||
const user = await this.userRepository.findById(userId);
|
|
||||||
if (!user || !user.stripeCustomerId) {
|
|
||||||
throw new Error('User or Stripe customer not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await this.stripe.billingPortal.sessions.create({
|
|
||||||
customer: user.stripeCustomerId,
|
|
||||||
return_url: returnUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Portal session created for user ${userId}`);
|
|
||||||
return session;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to create portal session:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel subscription
|
|
||||||
*/
|
|
||||||
async cancelSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
|
|
||||||
try {
|
|
||||||
const subscription = await this.stripe.subscriptions.update(subscriptionId, {
|
|
||||||
cancel_at_period_end: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Subscription cancelled: ${subscriptionId}`);
|
|
||||||
return subscription;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to cancel subscription:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reactivate subscription
|
|
||||||
*/
|
|
||||||
async reactivateSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
|
|
||||||
try {
|
|
||||||
const subscription = await this.stripe.subscriptions.update(subscriptionId, {
|
|
||||||
cancel_at_period_end: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Subscription reactivated: ${subscriptionId}`);
|
|
||||||
return subscription;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to reactivate subscription:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule subscription change for next billing period
|
|
||||||
*/
|
|
||||||
async scheduleSubscriptionChange(subscriptionId: string, newPlan: Plan): Promise<void> {
|
|
||||||
try {
|
|
||||||
const newPriceId = this.getPriceIdForPlan(newPlan);
|
|
||||||
if (!newPriceId) {
|
|
||||||
throw new Error(`No price configured for plan: ${newPlan}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current subscription
|
|
||||||
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
|
|
||||||
|
|
||||||
// Schedule the modification for the next billing period
|
|
||||||
await this.stripe.subscriptions.update(subscriptionId, {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: subscription.items.data[0].id,
|
|
||||||
price: newPriceId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
proration_behavior: 'none', // Don't prorate downgrades
|
|
||||||
billing_cycle_anchor: 'unchanged',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Subscription change scheduled: ${subscriptionId} to ${newPlan}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to schedule subscription change:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get subscription by ID
|
|
||||||
*/
|
|
||||||
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
|
|
||||||
try {
|
|
||||||
return await this.stripe.subscriptions.retrieve(subscriptionId);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get subscription:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construct webhook event
|
|
||||||
*/
|
|
||||||
constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event {
|
|
||||||
const webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET');
|
|
||||||
if (!webhookSecret) {
|
|
||||||
throw new Error('STRIPE_WEBHOOK_SECRET is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to construct webhook event:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create refund
|
|
||||||
*/
|
|
||||||
async createRefund(paymentIntentId: string, amount?: number): Promise<Stripe.Refund> {
|
|
||||||
try {
|
|
||||||
const refund = await this.stripe.refunds.create({
|
|
||||||
payment_intent: paymentIntentId,
|
|
||||||
amount, // If not provided, refunds the full amount
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Refund created: ${refund.id} for payment ${paymentIntentId}`);
|
|
||||||
return refund;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to create refund:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get customer payment methods
|
|
||||||
*/
|
|
||||||
async getCustomerPaymentMethods(customerId: string): Promise<Stripe.PaymentMethod[]> {
|
|
||||||
try {
|
|
||||||
const paymentMethods = await this.stripe.paymentMethods.list({
|
|
||||||
customer: customerId,
|
|
||||||
type: 'card',
|
|
||||||
});
|
|
||||||
|
|
||||||
return paymentMethods.data;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get customer payment methods:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update customer
|
|
||||||
*/
|
|
||||||
async updateCustomer(customerId: string, params: Stripe.CustomerUpdateParams): Promise<Stripe.Customer> {
|
|
||||||
try {
|
|
||||||
const customer = await this.stripe.customers.update(customerId, params);
|
|
||||||
this.logger.log(`Customer updated: ${customerId}`);
|
|
||||||
return customer;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to update customer:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get invoice by subscription
|
|
||||||
*/
|
|
||||||
async getLatestInvoice(subscriptionId: string): Promise<Stripe.Invoice | null> {
|
|
||||||
try {
|
|
||||||
const invoices = await this.stripe.invoices.list({
|
|
||||||
subscription: subscriptionId,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return invoices.data[0] || null;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get latest invoice:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get price ID for plan
|
|
||||||
*/
|
|
||||||
private getPriceIdForPlan(plan: Plan): string | null {
|
|
||||||
const priceMap: Record<Plan, string> = {
|
|
||||||
[Plan.BASIC]: '', // No price for free plan
|
|
||||||
[Plan.PRO]: this.configService.get<string>('STRIPE_PRO_PRICE_ID') || 'price_pro_monthly',
|
|
||||||
[Plan.MAX]: this.configService.get<string>('STRIPE_MAX_PRICE_ID') || 'price_max_monthly',
|
|
||||||
};
|
|
||||||
|
|
||||||
return priceMap[plan] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create usage record for metered billing (if needed in future)
|
|
||||||
*/
|
|
||||||
async createUsageRecord(subscriptionItemId: string, quantity: number): Promise<Stripe.UsageRecord> {
|
|
||||||
try {
|
|
||||||
const usageRecord = await this.stripe.subscriptionItems.createUsageRecord(
|
|
||||||
subscriptionItemId,
|
|
||||||
{
|
|
||||||
quantity,
|
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
|
||||||
action: 'increment',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Usage record created: ${quantity} units for ${subscriptionItemId}`);
|
|
||||||
return usageRecord;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to create usage record:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import Stripe from 'stripe';
|
|
||||||
import { StripeService } from './stripe.service';
|
|
||||||
import { PaymentsService } from '../payments.service';
|
|
||||||
import { Plan } from '@prisma/client';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class WebhookService {
|
|
||||||
private readonly logger = new Logger(WebhookService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly stripeService: StripeService,
|
|
||||||
private readonly paymentsService: PaymentsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle Stripe webhook
|
|
||||||
*/
|
|
||||||
async handleWebhook(payload: Buffer, signature: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const event = this.stripeService.constructWebhookEvent(payload, signature);
|
|
||||||
|
|
||||||
this.logger.log(`Received webhook: ${event.type}`);
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
case 'payment_intent.succeeded':
|
|
||||||
await this.handlePaymentIntentSucceeded(event.data.object as Stripe.PaymentIntent);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'payment_intent.payment_failed':
|
|
||||||
await this.handlePaymentIntentFailed(event.data.object as Stripe.PaymentIntent);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'customer.subscription.created':
|
|
||||||
await this.handleSubscriptionCreated(event.data.object as Stripe.Subscription);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'customer.subscription.updated':
|
|
||||||
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'customer.subscription.deleted':
|
|
||||||
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'invoice.payment_succeeded':
|
|
||||||
await this.handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'invoice.payment_failed':
|
|
||||||
await this.handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'checkout.session.completed':
|
|
||||||
await this.handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'customer.created':
|
|
||||||
await this.handleCustomerCreated(event.data.object as Stripe.Customer);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'customer.updated':
|
|
||||||
await this.handleCustomerUpdated(event.data.object as Stripe.Customer);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'customer.deleted':
|
|
||||||
await this.handleCustomerDeleted(event.data.object as Stripe.Customer);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
this.logger.warn(`Unhandled webhook event type: ${event.type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Successfully processed webhook: ${event.type}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle webhook:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle payment intent succeeded
|
|
||||||
*/
|
|
||||||
private async handlePaymentIntentSucceeded(paymentIntent: Stripe.PaymentIntent): Promise<void> {
|
|
||||||
try {
|
|
||||||
const customerId = paymentIntent.customer as string;
|
|
||||||
const amount = paymentIntent.amount;
|
|
||||||
const currency = paymentIntent.currency;
|
|
||||||
|
|
||||||
// Extract plan from metadata
|
|
||||||
const plan = paymentIntent.metadata.plan as Plan || Plan.BASIC;
|
|
||||||
|
|
||||||
await this.paymentsService.processSuccessfulPayment(
|
|
||||||
paymentIntent.id,
|
|
||||||
customerId,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
plan,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Payment succeeded: ${paymentIntent.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle payment intent succeeded:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle payment intent failed
|
|
||||||
*/
|
|
||||||
private async handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent): Promise<void> {
|
|
||||||
try {
|
|
||||||
const customerId = paymentIntent.customer as string;
|
|
||||||
const amount = paymentIntent.amount;
|
|
||||||
const currency = paymentIntent.currency;
|
|
||||||
|
|
||||||
await this.paymentsService.processFailedPayment(
|
|
||||||
paymentIntent.id,
|
|
||||||
customerId,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Payment failed: ${paymentIntent.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle payment intent failed:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle subscription created
|
|
||||||
*/
|
|
||||||
private async handleSubscriptionCreated(subscription: Stripe.Subscription): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.paymentsService.handleSubscriptionCreated(subscription);
|
|
||||||
this.logger.log(`Subscription created: ${subscription.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle subscription created:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle subscription updated
|
|
||||||
*/
|
|
||||||
private async handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.paymentsService.handleSubscriptionUpdated(subscription);
|
|
||||||
this.logger.log(`Subscription updated: ${subscription.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle subscription updated:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle subscription deleted
|
|
||||||
*/
|
|
||||||
private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.paymentsService.handleSubscriptionDeleted(subscription);
|
|
||||||
this.logger.log(`Subscription deleted: ${subscription.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle subscription deleted:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle invoice payment succeeded
|
|
||||||
*/
|
|
||||||
private async handleInvoicePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
|
|
||||||
try {
|
|
||||||
// This typically happens for recurring payments
|
|
||||||
if (invoice.subscription) {
|
|
||||||
const subscription = await this.stripeService.getSubscription(invoice.subscription as string);
|
|
||||||
await this.paymentsService.handleSubscriptionUpdated(subscription);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Invoice payment succeeded: ${invoice.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle invoice payment succeeded:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle invoice payment failed
|
|
||||||
*/
|
|
||||||
private async handleInvoicePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Handle failed recurring payment
|
|
||||||
// You might want to send notifications, attempt retries, etc.
|
|
||||||
|
|
||||||
this.logger.warn(`Invoice payment failed: ${invoice.id}`);
|
|
||||||
|
|
||||||
// If this is a subscription invoice, you might want to:
|
|
||||||
// 1. Send notification to user
|
|
||||||
// 2. Mark subscription as past due
|
|
||||||
// 3. Implement dunning management
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle invoice payment failed:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle checkout session completed
|
|
||||||
*/
|
|
||||||
private async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session): Promise<void> {
|
|
||||||
try {
|
|
||||||
// This is called when a checkout session is successfully completed
|
|
||||||
// The actual payment processing is handled by payment_intent.succeeded
|
|
||||||
|
|
||||||
this.logger.log(`Checkout session completed: ${session.id}`);
|
|
||||||
|
|
||||||
// You might want to:
|
|
||||||
// 1. Send confirmation email
|
|
||||||
// 2. Update user preferences
|
|
||||||
// 3. Track conversion metrics
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle checkout session completed:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle customer created
|
|
||||||
*/
|
|
||||||
private async handleCustomerCreated(customer: Stripe.Customer): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.logger.log(`Customer created: ${customer.id}`);
|
|
||||||
|
|
||||||
// Customer is usually created from our app, so no additional action needed
|
|
||||||
// But you might want to sync additional data or send welcome emails
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle customer created:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle customer updated
|
|
||||||
*/
|
|
||||||
private async handleCustomerUpdated(customer: Stripe.Customer): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.logger.log(`Customer updated: ${customer.id}`);
|
|
||||||
|
|
||||||
// You might want to sync customer data back to your database
|
|
||||||
// For example, if they update their email or billing address
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle customer updated:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle customer deleted
|
|
||||||
*/
|
|
||||||
private async handleCustomerDeleted(customer: Stripe.Customer): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.logger.log(`Customer deleted: ${customer.id}`);
|
|
||||||
|
|
||||||
// Handle customer deletion
|
|
||||||
// You might want to:
|
|
||||||
// 1. Clean up related data
|
|
||||||
// 2. Cancel active subscriptions
|
|
||||||
// 3. Update user records
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle customer deleted:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Job } from 'bullmq';
|
|
||||||
import { BatchProcessingJobData, JobProgress } from '../queue.service';
|
|
||||||
|
|
||||||
@Processor('batch-processing')
|
|
||||||
export class BatchProcessingProcessor extends WorkerHost {
|
|
||||||
private readonly logger = new Logger(BatchProcessingProcessor.name);
|
|
||||||
|
|
||||||
async process(job: Job<BatchProcessingJobData>): Promise<any> {
|
|
||||||
const { batchId, userId, imageIds, keywords } = job.data;
|
|
||||||
|
|
||||||
this.logger.log(`Processing batch: ${batchId} with ${imageIds.length} images`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Update progress - Starting
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage: 0,
|
|
||||||
processedCount: 0,
|
|
||||||
totalCount: imageIds.length,
|
|
||||||
status: 'starting',
|
|
||||||
});
|
|
||||||
|
|
||||||
let processedCount = 0;
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
// Process each image in the batch
|
|
||||||
for (const imageId of imageIds) {
|
|
||||||
try {
|
|
||||||
this.logger.log(`Processing image ${processedCount + 1}/${imageIds.length}: ${imageId}`);
|
|
||||||
|
|
||||||
// Update progress
|
|
||||||
const percentage = Math.round((processedCount / imageIds.length) * 90); // Reserve 10% for finalization
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage,
|
|
||||||
currentImage: imageId,
|
|
||||||
processedCount,
|
|
||||||
totalCount: imageIds.length,
|
|
||||||
status: 'processing-images',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulate individual image processing
|
|
||||||
await this.processIndividualImage(imageId, batchId, keywords);
|
|
||||||
processedCount++;
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
imageId,
|
|
||||||
success: true,
|
|
||||||
processedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to process image in batch: ${imageId}`, error.stack);
|
|
||||||
results.push({
|
|
||||||
imageId,
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
processedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finalize batch processing (90-100%)
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage: 95,
|
|
||||||
processedCount,
|
|
||||||
totalCount: imageIds.length,
|
|
||||||
status: 'finalizing',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update batch status in database
|
|
||||||
await this.finalizeBatchProcessing(batchId, results);
|
|
||||||
|
|
||||||
// Complete processing
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage: 100,
|
|
||||||
processedCount,
|
|
||||||
totalCount: imageIds.length,
|
|
||||||
status: 'completed',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Completed batch processing: ${batchId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
batchId,
|
|
||||||
totalImages: imageIds.length,
|
|
||||||
successfulImages: results.filter(r => r.success).length,
|
|
||||||
failedImages: results.filter(r => !r.success).length,
|
|
||||||
processingTime: Date.now() - job.timestamp,
|
|
||||||
results,
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to process batch: ${batchId}`, error.stack);
|
|
||||||
|
|
||||||
// Update progress - Failed
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage: 0,
|
|
||||||
processedCount: 0,
|
|
||||||
totalCount: imageIds.length,
|
|
||||||
status: 'failed',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark batch as failed in database
|
|
||||||
await this.markBatchAsFailed(batchId, error.message);
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnWorkerEvent('completed')
|
|
||||||
onCompleted(job: Job) {
|
|
||||||
this.logger.log(`Batch processing completed: ${job.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnWorkerEvent('failed')
|
|
||||||
onFailed(job: Job, err: Error) {
|
|
||||||
this.logger.error(`Batch processing failed: ${job.id}`, err.stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnWorkerEvent('progress')
|
|
||||||
onProgress(job: Job, progress: JobProgress) {
|
|
||||||
this.logger.debug(`Batch processing progress: ${job.id} - ${progress.percentage}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update job progress
|
|
||||||
*/
|
|
||||||
private async updateProgress(job: Job, progress: JobProgress): Promise<void> {
|
|
||||||
await job.updateProgress(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process an individual image within the batch
|
|
||||||
* @param imageId Image ID to process
|
|
||||||
* @param batchId Batch ID
|
|
||||||
* @param keywords Keywords for processing
|
|
||||||
*/
|
|
||||||
private async processIndividualImage(
|
|
||||||
imageId: string,
|
|
||||||
batchId: string,
|
|
||||||
keywords?: string[]
|
|
||||||
): Promise<void> {
|
|
||||||
// Simulate individual image processing time
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000));
|
|
||||||
|
|
||||||
// TODO: Implement actual image processing logic
|
|
||||||
// This would typically:
|
|
||||||
// 1. Fetch image from storage
|
|
||||||
// 2. Perform AI vision analysis
|
|
||||||
// 3. Generate SEO filename
|
|
||||||
// 4. Update image record in database
|
|
||||||
|
|
||||||
this.logger.debug(`Processed individual image: ${imageId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finalize batch processing and update database
|
|
||||||
* @param batchId Batch ID
|
|
||||||
* @param results Processing results for all images
|
|
||||||
*/
|
|
||||||
private async finalizeBatchProcessing(batchId: string, results: any[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const successCount = results.filter(r => r.success).length;
|
|
||||||
const failCount = results.filter(r => !r.success).length;
|
|
||||||
|
|
||||||
// TODO: Update batch record in database
|
|
||||||
// This would typically:
|
|
||||||
// 1. Update batch status to DONE or ERROR
|
|
||||||
// 2. Set processedImages and failedImages counts
|
|
||||||
// 3. Set completedAt timestamp
|
|
||||||
// 4. Update any batch metadata
|
|
||||||
|
|
||||||
this.logger.log(`Finalized batch ${batchId}: ${successCount} successful, ${failCount} failed`);
|
|
||||||
|
|
||||||
// Simulate database update
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to finalize batch: ${batchId}`, error.stack);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark batch as failed in database
|
|
||||||
* @param batchId Batch ID
|
|
||||||
* @param errorMessage Error message
|
|
||||||
*/
|
|
||||||
private async markBatchAsFailed(batchId: string, errorMessage: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
// TODO: Update batch record in database
|
|
||||||
// This would typically:
|
|
||||||
// 1. Update batch status to ERROR
|
|
||||||
// 2. Set error message in metadata
|
|
||||||
// 3. Set completedAt timestamp
|
|
||||||
|
|
||||||
this.logger.log(`Marked batch as failed: ${batchId}`);
|
|
||||||
|
|
||||||
// Simulate database update
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to mark batch as failed: ${batchId}`, error.stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate batch processing statistics
|
|
||||||
* @param results Processing results
|
|
||||||
* @returns Statistics object
|
|
||||||
*/
|
|
||||||
private calculateBatchStats(results: any[]) {
|
|
||||||
const total = results.length;
|
|
||||||
const successful = results.filter(r => r.success).length;
|
|
||||||
const failed = results.filter(r => !r.success).length;
|
|
||||||
const successRate = total > 0 ? (successful / total) * 100 : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
total,
|
|
||||||
successful,
|
|
||||||
failed,
|
|
||||||
successRate: Math.round(successRate * 100) / 100,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send batch completion notification
|
|
||||||
* @param batchId Batch ID
|
|
||||||
* @param userId User ID
|
|
||||||
* @param stats Batch statistics
|
|
||||||
*/
|
|
||||||
private async sendBatchCompletionNotification(
|
|
||||||
batchId: string,
|
|
||||||
userId: string,
|
|
||||||
stats: any
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
// TODO: Implement notification system
|
|
||||||
// This could send email, push notification, or WebSocket event
|
|
||||||
|
|
||||||
this.logger.log(`Sent batch completion notification: ${batchId} to user: ${userId}`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to send batch completion notification: ${batchId}`, error.stack);
|
|
||||||
// Don't throw error - notification failure shouldn't fail the job
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Job } from 'bullmq';
|
|
||||||
import { ImageProcessingJobData, JobProgress } from '../queue.service';
|
|
||||||
|
|
||||||
@Processor('image-processing')
|
|
||||||
export class ImageProcessingProcessor extends WorkerHost {
|
|
||||||
private readonly logger = new Logger(ImageProcessingProcessor.name);
|
|
||||||
|
|
||||||
async process(job: Job<ImageProcessingJobData>): Promise<any> {
|
|
||||||
const { imageId, batchId, s3Key, originalName, userId, keywords } = job.data;
|
|
||||||
|
|
||||||
this.logger.log(`Processing image: ${imageId} from batch: ${batchId}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Update progress - Starting
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage: 0,
|
|
||||||
currentImage: originalName,
|
|
||||||
processedCount: 0,
|
|
||||||
totalCount: 1,
|
|
||||||
status: 'starting',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1: Download image from storage (10%)
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage: 10,
|
|
||||||
currentImage: originalName,
|
|
||||||
processedCount: 0,
|
|
||||||
totalCount: 1,
|
|
||||||
status: 'downloading',
|
|
||||||
});
|
|
||||||
// TODO: Implement actual image download from storage
|
|
||||||
|
|
||||||
// Step 2: AI Vision Analysis (50%)
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage: 30,
|
|
||||||
currentImage: originalName,
|
|
||||||
processedCount: 0,
|
|
||||||
totalCount: 1,
|
|
||||||
status: 'analyzing',
|
|
||||||
});
|
|
||||||
|
|
||||||
const visionTags = await this.performVisionAnalysis(s3Key, keywords);
|
|
||||||
|
|
||||||
// Step 3: Generate SEO filename (70%)
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage: 70,
|
|
||||||
currentImage: originalName,
|
|
||||||
processedCount: 0,
|
|
||||||
totalCount: 1,
|
|
||||||
status: 'generating-filename',
|
|
||||||
});
|
|
||||||
|
|
||||||
const proposedName = await this.generateSeoFilename(visionTags, originalName, keywords);
|
|
||||||
|
|
||||||
// Step 4: Update database (90%)
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage: 90,
|
|
||||||
currentImage: originalName,
|
|
||||||
processedCount: 0,
|
|
||||||
totalCount: 1,
|
|
||||||
status: 'updating-database',
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Update image record in database with vision tags and proposed name
|
|
||||||
|
|
||||||
// Step 5: Complete (100%)
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage: 100,
|
|
||||||
currentImage: originalName,
|
|
||||||
processedCount: 1,
|
|
||||||
totalCount: 1,
|
|
||||||
status: 'completed',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Completed processing image: ${imageId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
imageId,
|
|
||||||
success: true,
|
|
||||||
proposedName,
|
|
||||||
visionTags,
|
|
||||||
processingTime: Date.now() - job.timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to process image: ${imageId}`, error.stack);
|
|
||||||
|
|
||||||
// Update progress - Failed
|
|
||||||
await this.updateProgress(job, {
|
|
||||||
percentage: 0,
|
|
||||||
currentImage: originalName,
|
|
||||||
processedCount: 0,
|
|
||||||
totalCount: 1,
|
|
||||||
status: 'failed',
|
|
||||||
});
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnWorkerEvent('completed')
|
|
||||||
onCompleted(job: Job) {
|
|
||||||
this.logger.log(`Image processing completed: ${job.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnWorkerEvent('failed')
|
|
||||||
onFailed(job: Job, err: Error) {
|
|
||||||
this.logger.error(`Image processing failed: ${job.id}`, err.stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnWorkerEvent('progress')
|
|
||||||
onProgress(job: Job, progress: JobProgress) {
|
|
||||||
this.logger.debug(`Image processing progress: ${job.id} - ${progress.percentage}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update job progress
|
|
||||||
*/
|
|
||||||
private async updateProgress(job: Job, progress: JobProgress): Promise<void> {
|
|
||||||
await job.updateProgress(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform AI vision analysis on the image
|
|
||||||
* @param s3Key Storage key for the image
|
|
||||||
* @param keywords Additional keywords for context
|
|
||||||
* @returns Vision analysis results
|
|
||||||
*/
|
|
||||||
private async performVisionAnalysis(s3Key: string, keywords?: string[]): Promise<any> {
|
|
||||||
// Simulate AI processing time
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
|
|
||||||
// TODO: Implement actual AI vision analysis
|
|
||||||
// This would integrate with OpenAI GPT-4 Vision or similar service
|
|
||||||
|
|
||||||
// Mock response for now
|
|
||||||
return {
|
|
||||||
objects: ['modern', 'kitchen', 'appliances', 'interior'],
|
|
||||||
colors: ['white', 'stainless-steel', 'gray'],
|
|
||||||
scene: 'modern kitchen interior',
|
|
||||||
description: 'A modern kitchen with stainless steel appliances and white cabinets',
|
|
||||||
confidence: 0.92,
|
|
||||||
aiModel: 'gpt-4-vision',
|
|
||||||
processingTime: 2.1,
|
|
||||||
keywords: keywords || [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate SEO-friendly filename from vision analysis
|
|
||||||
* @param visionTags AI vision analysis results
|
|
||||||
* @param originalName Original filename
|
|
||||||
* @param keywords Additional keywords
|
|
||||||
* @returns SEO-optimized filename
|
|
||||||
*/
|
|
||||||
private async generateSeoFilename(
|
|
||||||
visionTags: any,
|
|
||||||
originalName: string,
|
|
||||||
keywords?: string[]
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
|
||||||
// Combine AI-detected objects with user keywords
|
|
||||||
const allKeywords = [
|
|
||||||
...(visionTags.objects || []),
|
|
||||||
...(keywords || []),
|
|
||||||
...(visionTags.colors || []).slice(0, 2), // Limit colors
|
|
||||||
];
|
|
||||||
|
|
||||||
// Remove duplicates and filter out common words
|
|
||||||
const filteredKeywords = [...new Set(allKeywords)]
|
|
||||||
.filter(keyword => keyword.length > 2)
|
|
||||||
.filter(keyword => !['the', 'and', 'with', 'for', 'are', 'was'].includes(keyword.toLowerCase()))
|
|
||||||
.slice(0, 5); // Limit to 5 keywords for filename
|
|
||||||
|
|
||||||
// Create SEO-friendly filename
|
|
||||||
let filename = filteredKeywords
|
|
||||||
.join('-')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
|
||||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
||||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
|
||||||
.substring(0, 80); // Limit length
|
|
||||||
|
|
||||||
// Get file extension from original name
|
|
||||||
const extension = originalName.split('.').pop()?.toLowerCase() || 'jpg';
|
|
||||||
|
|
||||||
// Ensure filename is not empty
|
|
||||||
if (!filename) {
|
|
||||||
filename = 'image';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${filename}.${extension}`;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to generate SEO filename', error.stack);
|
|
||||||
return originalName; // Fallback to original name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<string>('REDIS_HOST', 'localhost'),
|
|
||||||
port: configService.get<number>('REDIS_PORT', 6379),
|
|
||||||
password: configService.get<string>('REDIS_PASSWORD'),
|
|
||||||
db: configService.get<number>('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 {}
|
|
||||||
|
|
@ -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<Job> {
|
|
||||||
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<Job> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<Job[]> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<string>('MINIO_ENDPOINT', 'localhost'),
|
|
||||||
port: this.configService.get<number>('MINIO_PORT', 9000),
|
|
||||||
useSSL: this.configService.get<boolean>('MINIO_USE_SSL', false),
|
|
||||||
accessKey: this.configService.get<string>('MINIO_ACCESS_KEY', 'minioadmin'),
|
|
||||||
secretKey: this.configService.get<string>('MINIO_SECRET_KEY', 'minioadmin'),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.bucketName = this.configService.get<string>('MINIO_BUCKET_NAME', 'seo-image-renamer');
|
|
||||||
this.initializeBucket();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the bucket if it doesn't exist
|
|
||||||
*/
|
|
||||||
private async initializeBucket(): Promise<void> {
|
|
||||||
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<UploadResult> {
|
|
||||||
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<NodeJS.ReadableStream> {
|
|
||||||
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<any> {
|
|
||||||
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<void> {
|
|
||||||
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<string[]> {
|
|
||||||
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<void> {
|
|
||||||
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<string> {
|
|
||||||
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<boolean> {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<ProcessedUpload[]> {
|
|
||||||
this.logger.log(`Processing ${files.length} files for batch: ${batchId}`);
|
|
||||||
|
|
||||||
// Validate files
|
|
||||||
this.validateFiles(files);
|
|
||||||
|
|
||||||
const results: ProcessedUpload[] = [];
|
|
||||||
const duplicateHashes = new Set<string>();
|
|
||||||
|
|
||||||
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<ProcessedUpload> {
|
|
||||||
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<ImageMetadata> {
|
|
||||||
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<Buffer> {
|
|
||||||
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<Buffer> {
|
|
||||||
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<boolean> {
|
|
||||||
// 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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<UserResponseDto> {
|
|
||||||
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<UserResponseDto> {
|
|
||||||
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<UserStatsDto> {
|
|
||||||
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<UserResponseDto> {
|
|
||||||
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<UserResponseDto> {
|
|
||||||
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<UserResponseDto> {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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<UserResponseDto> {
|
|
||||||
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<UserResponseDto> {
|
|
||||||
return await this.findOne(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user profile
|
|
||||||
*/
|
|
||||||
async updateProfile(userId: string, updateData: UpdateUserDto): Promise<UserResponseDto> {
|
|
||||||
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<UserStatsDto> {
|
|
||||||
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<UserResponseDto> {
|
|
||||||
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<UserResponseDto> {
|
|
||||||
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<boolean> {
|
|
||||||
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<User> {
|
|
||||||
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<UserResponseDto> {
|
|
||||||
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<UserResponseDto> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@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<string, ClientConnection>();
|
|
||||||
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,476 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>SEO Image Renamer - AI-Powered Image SEO Tool</title>
|
|
||||||
<link rel="stylesheet" href="styles.css">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
||||||
<script src="https://js.stripe.com/v3/"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Auth Modal -->
|
|
||||||
<div id="auth-modal" class="modal" style="display: none;">
|
|
||||||
<div class="modal-content">
|
|
||||||
<span class="close">×</span>
|
|
||||||
<div id="auth-content">
|
|
||||||
<h2>Sign In to Continue</h2>
|
|
||||||
<p>Please sign in to access the SEO Image Renamer</p>
|
|
||||||
<button id="google-signin-btn" class="btn btn-primary">
|
|
||||||
<i class="fab fa-google"></i> Sign in with Google
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subscription Modal -->
|
|
||||||
<div id="subscription-modal" class="modal" style="display: none;">
|
|
||||||
<div class="modal-content">
|
|
||||||
<span class="close">×</span>
|
|
||||||
<div id="subscription-content">
|
|
||||||
<h2>Upgrade Your Plan</h2>
|
|
||||||
<p>You've reached your monthly quota. Upgrade to continue processing images.</p>
|
|
||||||
<div class="pricing-cards">
|
|
||||||
<div class="pricing-card">
|
|
||||||
<h3>Pro</h3>
|
|
||||||
<div class="price">$9<span>/month</span></div>
|
|
||||||
<ul>
|
|
||||||
<li>500 images per month</li>
|
|
||||||
<li>AI-powered naming</li>
|
|
||||||
<li>Priority support</li>
|
|
||||||
</ul>
|
|
||||||
<button class="btn btn-primary upgrade-btn" data-plan="PRO">Upgrade to Pro</button>
|
|
||||||
</div>
|
|
||||||
<div class="pricing-card">
|
|
||||||
<h3>Max</h3>
|
|
||||||
<div class="price">$19<span>/month</span></div>
|
|
||||||
<ul>
|
|
||||||
<li>1000 images per month</li>
|
|
||||||
<li>AI-powered naming</li>
|
|
||||||
<li>Advanced analytics</li>
|
|
||||||
</ul>
|
|
||||||
<button class="btn btn-primary upgrade-btn" data-plan="MAX">Upgrade to Max</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<div class="container">
|
|
||||||
<div class="logo">
|
|
||||||
<h1><i class="fas fa-image"></i> SEO Image Renamer</h1>
|
|
||||||
</div>
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li><a href="#features">Features</a></li>
|
|
||||||
<li><a href="#how-it-works">How It Works</a></li>
|
|
||||||
<li><a href="#pricing">Pricing</a></li>
|
|
||||||
<li id="user-menu" style="display: none;">
|
|
||||||
<div class="user-info">
|
|
||||||
<img id="user-avatar" src="" alt="User" class="user-avatar">
|
|
||||||
<span id="user-name"></span>
|
|
||||||
<div class="user-dropdown">
|
|
||||||
<a href="#" id="dashboard-link">Dashboard</a>
|
|
||||||
<a href="#" id="billing-link">Billing</a>
|
|
||||||
<a href="#" id="logout-link">Logout</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li id="signin-menu">
|
|
||||||
<a href="#" class="btn btn-primary" id="signin-btn">Sign In</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<div class="mobile-menu">
|
|
||||||
<i class="fas fa-bars"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<!-- User Dashboard (hidden by default) -->
|
|
||||||
<section id="dashboard-section" class="dashboard-section" style="display: none;">
|
|
||||||
<div class="container">
|
|
||||||
<div class="dashboard-header">
|
|
||||||
<h2>Dashboard</h2>
|
|
||||||
<div class="quota-info">
|
|
||||||
<div class="quota-bar">
|
|
||||||
<div class="quota-fill" id="quota-fill"></div>
|
|
||||||
</div>
|
|
||||||
<div class="quota-text">
|
|
||||||
<span id="quota-used">0</span> / <span id="quota-limit">50</span> images used this month
|
|
||||||
</div>
|
|
||||||
<div class="quota-reset">
|
|
||||||
Resets on: <span id="quota-reset-date"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dashboard-stats">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<i class="fas fa-images"></i>
|
|
||||||
</div>
|
|
||||||
<div class="stat-info">
|
|
||||||
<div class="stat-number" id="total-processed">0</div>
|
|
||||||
<div class="stat-label">Images Processed</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<i class="fas fa-folder"></i>
|
|
||||||
</div>
|
|
||||||
<div class="stat-info">
|
|
||||||
<div class="stat-number" id="total-batches">0</div>
|
|
||||||
<div class="stat-label">Batches Created</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<i class="fas fa-download"></i>
|
|
||||||
</div>
|
|
||||||
<div class="stat-info">
|
|
||||||
<div class="stat-number" id="total-downloads">0</div>
|
|
||||||
<div class="stat-label">Downloads</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="recent-batches">
|
|
||||||
<h3>Recent Batches</h3>
|
|
||||||
<div id="recent-batches-list" class="batches-list">
|
|
||||||
<!-- Recent batches will be loaded here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Hero Section -->
|
|
||||||
<section class="hero" id="hero-section">
|
|
||||||
<div class="container">
|
|
||||||
<div class="hero-grid">
|
|
||||||
<div class="hero-content">
|
|
||||||
<div class="hero-badge">
|
|
||||||
<i class="fas fa-bolt"></i>
|
|
||||||
<span>AI-Powered</span>
|
|
||||||
</div>
|
|
||||||
<h1>Save time! Bulk rename your images individually for better SEO performance</h1>
|
|
||||||
<p>Transform your image SEO workflow with AI that analyzes content and generates perfect filenames automatically. No more manual renaming - just upload, enhance, and download.</p>
|
|
||||||
|
|
||||||
<div class="hero-features">
|
|
||||||
<div class="mini-feature">
|
|
||||||
<i class="fas fa-eye"></i>
|
|
||||||
<span>AI Vision Analysis</span>
|
|
||||||
</div>
|
|
||||||
<div class="mini-feature">
|
|
||||||
<i class="fas fa-magic"></i>
|
|
||||||
<span>Smart Keyword Enhancement</span>
|
|
||||||
</div>
|
|
||||||
<div class="mini-feature">
|
|
||||||
<i class="fas fa-download"></i>
|
|
||||||
<span>Instant ZIP Download</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-stats">
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-number" id="global-images-processed">10k+</span>
|
|
||||||
<span class="stat-label">Images Processed</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-number">95%</span>
|
|
||||||
<span class="stat-label">Time Saved</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-upload">
|
|
||||||
<div id="drop-area" class="drop-area">
|
|
||||||
<div class="drop-area-content">
|
|
||||||
<div class="upload-icon">
|
|
||||||
<i class="fas fa-cloud-upload-alt"></i>
|
|
||||||
</div>
|
|
||||||
<h3>Drop your images here</h3>
|
|
||||||
<p>or click to browse files</p>
|
|
||||||
<button id="browse-btn" class="upload-btn">
|
|
||||||
<i class="fas fa-folder-open"></i>
|
|
||||||
<span>Choose Files</span>
|
|
||||||
</button>
|
|
||||||
<input type="file" id="file-input" accept="image/*" multiple style="display: none;">
|
|
||||||
<div class="supported-formats">
|
|
||||||
<span>Supports: JPG, PNG, WEBP, GIF</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Workflow Section -->
|
|
||||||
<section id="workflow-section" class="workflow-section" style="display: none;">
|
|
||||||
<div class="container">
|
|
||||||
<div id="keywords-section" class="keywords-section">
|
|
||||||
<div class="workflow-step">
|
|
||||||
<div class="step-header">
|
|
||||||
<i class="fas fa-tags"></i>
|
|
||||||
<h3>Step 1: Add Your Keywords</h3>
|
|
||||||
<p>Help our AI understand your content better</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="keywords-input">
|
|
||||||
<input type="text" id="keyword-input" placeholder="Enter keywords (e.g., beach vacation, summer party)">
|
|
||||||
<button id="enhance-btn" class="btn btn-primary" disabled>
|
|
||||||
<i class="fas fa-magic"></i> Enhance with AI
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="keywords-display" class="keywords-display">
|
|
||||||
<!-- Keywords will be displayed here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Processing Status -->
|
|
||||||
<div id="processing-section" class="processing-section" style="display: none;">
|
|
||||||
<div class="workflow-step">
|
|
||||||
<div class="step-header">
|
|
||||||
<i class="fas fa-cogs"></i>
|
|
||||||
<h3>Processing Your Images</h3>
|
|
||||||
<p>Our AI is analyzing and renaming your images</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="processing-status">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div class="progress-fill" id="processing-progress"></div>
|
|
||||||
</div>
|
|
||||||
<div class="progress-text">
|
|
||||||
<span id="processing-status-text">Preparing batch...</span>
|
|
||||||
<span id="processing-percentage">0%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="processing-details" class="processing-details">
|
|
||||||
<!-- Processing details will be shown here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Results Section -->
|
|
||||||
<div id="images-preview" class="images-preview" style="display: none;">
|
|
||||||
<div class="workflow-step">
|
|
||||||
<div class="step-header">
|
|
||||||
<i class="fas fa-images"></i>
|
|
||||||
<h3>Step 2: Review & Download</h3>
|
|
||||||
<p>Your AI-generated filenames are ready</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="images-container" class="images-container">
|
|
||||||
<!-- Images will be displayed here -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button id="download-btn" class="btn btn-success btn-large" disabled>
|
|
||||||
<i class="fas fa-download"></i> Download Renamed Images as ZIP
|
|
||||||
</button>
|
|
||||||
<button id="start-over-btn" class="btn btn-outline">
|
|
||||||
<i class="fas fa-redo"></i> Start Over
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Features Section -->
|
|
||||||
<section id="features" class="features">
|
|
||||||
<div class="container">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>Powerful Features for Better SEO</h2>
|
|
||||||
<p>Everything you need to optimize your images for search engines</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="features-grid">
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-robot"></i>
|
|
||||||
</div>
|
|
||||||
<h3>AI-Powered Naming</h3>
|
|
||||||
<p>Advanced AI generates SEO-friendly filenames that help your images rank higher in search results.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-eye"></i>
|
|
||||||
</div>
|
|
||||||
<h3>Image Recognition</h3>
|
|
||||||
<p>AI analyzes your images to understand content and context for more accurate naming.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-key"></i>
|
|
||||||
</div>
|
|
||||||
<h3>Keyword Enhancement</h3>
|
|
||||||
<p>Enhance your keywords with AI-suggested synonyms for better SEO performance.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-file-archive"></i>
|
|
||||||
</div>
|
|
||||||
<h3>Easy Download</h3>
|
|
||||||
<p>Download all your renamed images in a single ZIP file with preserved EXIF data.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- How It Works Section -->
|
|
||||||
<section id="how-it-works" class="how-it-works">
|
|
||||||
<div class="container">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>How It Works</h2>
|
|
||||||
<p>Get better SEO for your images in just three simple steps</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="steps">
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">1</div>
|
|
||||||
<h3>Upload Images</h3>
|
|
||||||
<p>Drag and drop your images or browse your files to upload them to our platform.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">2</div>
|
|
||||||
<h3>Add Keywords</h3>
|
|
||||||
<p>Provide keywords that describe your images, or let our AI enhance them for better SEO.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">3</div>
|
|
||||||
<h3>Download & Implement</h3>
|
|
||||||
<p>Download your renamed images as a ZIP file and use them on your website.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Pricing Section -->
|
|
||||||
<section id="pricing" class="pricing">
|
|
||||||
<div class="container">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>Simple, Transparent Pricing</h2>
|
|
||||||
<p>Choose the plan that works best for you</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pricing-grid">
|
|
||||||
<div class="pricing-card">
|
|
||||||
<h3>Basic</h3>
|
|
||||||
<div class="price">$0<span>/month</span></div>
|
|
||||||
<ul>
|
|
||||||
<li>50 images per month</li>
|
|
||||||
<li>AI-powered naming</li>
|
|
||||||
<li>Keyword enhancement</li>
|
|
||||||
<li>ZIP download</li>
|
|
||||||
</ul>
|
|
||||||
<button class="btn btn-outline pricing-btn" data-plan="BASIC">Get Started</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pricing-card featured">
|
|
||||||
<div class="featured-badge">Most Popular</div>
|
|
||||||
<h3>Pro</h3>
|
|
||||||
<div class="price">$9<span>/month</span></div>
|
|
||||||
<ul>
|
|
||||||
<li>500 images per month</li>
|
|
||||||
<li>AI-powered naming</li>
|
|
||||||
<li>Keyword enhancement</li>
|
|
||||||
<li>ZIP download</li>
|
|
||||||
<li>Priority support</li>
|
|
||||||
</ul>
|
|
||||||
<button class="btn btn-primary pricing-btn" data-plan="PRO">Get Started</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pricing-card">
|
|
||||||
<h3>Max</h3>
|
|
||||||
<div class="price">$19<span>/month</span></div>
|
|
||||||
<ul>
|
|
||||||
<li>1000 images per month</li>
|
|
||||||
<li>AI-powered naming</li>
|
|
||||||
<li>Keyword enhancement</li>
|
|
||||||
<li>ZIP download</li>
|
|
||||||
<li>Priority support</li>
|
|
||||||
<li>Advanced analytics</li>
|
|
||||||
</ul>
|
|
||||||
<button class="btn btn-outline pricing-btn" data-plan="MAX">Get Started</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<div class="container">
|
|
||||||
<div class="footer-content">
|
|
||||||
<div class="footer-logo">
|
|
||||||
<h2><i class="fas fa-image"></i> SEO Image Renamer</h2>
|
|
||||||
<p>AI-powered image SEO optimization</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer-links">
|
|
||||||
<div class="footer-column">
|
|
||||||
<h4>Product</h4>
|
|
||||||
<ul>
|
|
||||||
<li><a href="#features">Features</a></li>
|
|
||||||
<li><a href="#how-it-works">How It Works</a></li>
|
|
||||||
<li><a href="#pricing">Pricing</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer-column">
|
|
||||||
<h4>Company</h4>
|
|
||||||
<ul>
|
|
||||||
<li><a href="#">About Us</a></li>
|
|
||||||
<li><a href="#">Blog</a></li>
|
|
||||||
<li><a href="#">Contact</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer-column">
|
|
||||||
<h4>Legal</h4>
|
|
||||||
<ul>
|
|
||||||
<li><a href="#">Privacy Policy</a></li>
|
|
||||||
<li><a href="#">Terms of Service</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer-bottom">
|
|
||||||
<p>© 2025 SEO Image Renamer. All rights reserved.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Loading Overlay -->
|
|
||||||
<div id="loading-overlay" class="loading-overlay" style="display: none;">
|
|
||||||
<div class="loading-spinner">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
<p id="loading-text">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scripts -->
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.4/socket.io.js"></script>
|
|
||||||
<script src="config.js"></script>
|
|
||||||
<script src="api.js"></script>
|
|
||||||
<script src="auth.js"></script>
|
|
||||||
<script src="upload.js"></script>
|
|
||||||
<script src="processing.js"></script>
|
|
||||||
<script src="payments.js"></script>
|
|
||||||
<script src="dashboard.js"></script>
|
|
||||||
<script src="script.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
112
tsconfig.json
112
tsconfig.json
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue