Compare commits
25 commits
90016254a9
...
d53cbb6757
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d53cbb6757 | ||
|
|
46f7d47119 | ||
|
|
68ec648c2c | ||
|
|
f3870f56c9 | ||
|
|
8f4d069870 | ||
|
|
b554f69516 | ||
|
|
ed5f745a51 | ||
|
|
2add73a264 | ||
|
|
d54dd44cf9 | ||
|
|
b39c5681d3 | ||
|
|
149a4da024 | ||
|
|
d2c988303f | ||
|
|
0197a2f7ca | ||
|
|
9514a2d0a3 | ||
|
|
e7e09d5e2c | ||
|
|
9366cfa94f | ||
|
|
ff310d97df | ||
|
|
ebf20ea7f9 | ||
|
|
4cae1f981f | ||
|
|
29e63402b9 | ||
|
|
a57d7824a3 | ||
|
|
451243b1af | ||
|
|
e294e050ee | ||
|
|
f9fe71d286 | ||
|
|
7f719bcaec |
98 changed files with 18037 additions and 0 deletions
240
.env.example
Normal file
240
.env.example
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
# 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
Normal file
204
.eslintrc.js
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
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'
|
||||||
|
]
|
||||||
|
};
|
||||||
397
.forgejo/workflows/ci.yml
Normal file
397
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
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
Normal file
366
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
# =============================================================================
|
||||||
|
# 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
Normal file
86
.prettierrc
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
{
|
||||||
|
"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
Normal file
125
CLAUDE.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# 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
Normal file
126
Dockerfile
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# 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"]
|
||||||
82
cypress.config.js
Normal file
82
cypress.config.js
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
173
cypress/e2e/auth.cy.ts
Normal file
173
cypress/e2e/auth.cy.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
135
docker-compose.dev.yml
Normal file
135
docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
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
|
||||||
255
docker-compose.yml
Normal file
255
docker-compose.yml
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
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
|
||||||
41
jest.config.js
Normal file
41
jest.config.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
151
k8s/api-deployment.yaml
Normal file
151
k8s/api-deployment.yaml
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
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
|
||||||
28
k8s/configmap.yaml
Normal file
28
k8s/configmap.yaml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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"
|
||||||
172
k8s/frontend-deployment.yaml
Normal file
172
k8s/frontend-deployment.yaml
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
k8s/namespace.yaml
Normal file
7
k8s/namespace.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: seo-image-renamer
|
||||||
|
labels:
|
||||||
|
app: seo-image-renamer
|
||||||
|
environment: production
|
||||||
44
k8s/secrets.yaml
Normal file
44
k8s/secrets.yaml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# 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=
|
||||||
100
k8s/worker-deployment.yaml
Normal file
100
k8s/worker-deployment.yaml
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
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
|
||||||
15
logs/.d56b634021edaec5c4c8bdac5c3c6d66231098da-audit.json
Normal file
15
logs/.d56b634021edaec5c4c8bdac5c3c6d66231098da-audit.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
2
logs/mcp-puppeteer-2025-08-05.log
Normal file
2
logs/mcp-puppeteer-2025-08-05.log
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{"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
Normal file
78
package.json
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
43
packages/api/.env.example
Normal file
43
packages/api/.env.example
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# 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"
|
||||||
113
packages/api/package.json
Normal file
113
packages/api/package.json
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
179
packages/api/prisma/schema.prisma
Normal file
179
packages/api/prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
391
packages/api/prisma/seed.ts
Normal file
391
packages/api/prisma/seed.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
475
packages/api/src/admin/admin.controller.ts
Normal file
475
packages/api/src/admin/admin.controller.ts
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/api/src/admin/admin.module.ts
Normal file
31
packages/api/src/admin/admin.module.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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 {}
|
||||||
64
packages/api/src/app.module.ts
Normal file
64
packages/api/src/app.module.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
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/*');
|
||||||
|
}
|
||||||
|
}
|
||||||
235
packages/api/src/auth/auth.controller.ts
Normal file
235
packages/api/src/auth/auth.controller.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
84
packages/api/src/auth/auth.guard.ts
Normal file
84
packages/api/src/auth/auth.guard.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
33
packages/api/src/auth/auth.module.ts
Normal file
33
packages/api/src/auth/auth.module.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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 {}
|
||||||
206
packages/api/src/auth/auth.service.spec.ts
Normal file
206
packages/api/src/auth/auth.service.spec.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
187
packages/api/src/auth/auth.service.ts
Normal file
187
packages/api/src/auth/auth.service.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
137
packages/api/src/auth/dto/auth.dto.ts
Normal file
137
packages/api/src/auth/dto/auth.dto.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
68
packages/api/src/auth/google.strategy.ts
Normal file
68
packages/api/src/auth/google.strategy.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
packages/api/src/auth/jwt.strategy.ts
Normal file
56
packages/api/src/auth/jwt.strategy.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
packages/api/src/batches/batch.entity.ts
Normal file
227
packages/api/src/batches/batch.entity.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
275
packages/api/src/batches/batches.controller.ts
Normal file
275
packages/api/src/batches/batches.controller.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
packages/api/src/batches/batches.module.ts
Normal file
22
packages/api/src/batches/batches.module.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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 {}
|
||||||
515
packages/api/src/batches/batches.service.ts
Normal file
515
packages/api/src/batches/batches.service.ts
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
packages/api/src/batches/dto/batch-status.dto.ts
Normal file
142
packages/api/src/batches/dto/batch-status.dto.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
49
packages/api/src/batches/dto/create-batch.dto.ts
Normal file
49
packages/api/src/batches/dto/create-batch.dto.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
89
packages/api/src/common/middleware/rate-limit.middleware.ts
Normal file
89
packages/api/src/common/middleware/rate-limit.middleware.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
packages/api/src/common/middleware/security.middleware.ts
Normal file
102
packages/api/src/common/middleware/security.middleware.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/api/src/database/database.module.ts
Normal file
27
packages/api/src/database/database.module.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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 {}
|
||||||
138
packages/api/src/database/prisma.service.ts
Normal file
138
packages/api/src/database/prisma.service.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
349
packages/api/src/database/repositories/batch.repository.ts
Normal file
349
packages/api/src/database/repositories/batch.repository.ts
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
457
packages/api/src/database/repositories/image.repository.ts
Normal file
457
packages/api/src/database/repositories/image.repository.ts
Normal file
|
|
@ -0,0 +1,457 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
437
packages/api/src/database/repositories/payment.repository.ts
Normal file
437
packages/api/src/database/repositories/payment.repository.ts
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
376
packages/api/src/database/repositories/user.repository.ts
Normal file
376
packages/api/src/database/repositories/user.repository.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
225
packages/api/src/download/download.controller.ts
Normal file
225
packages/api/src/download/download.controller.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/api/src/download/download.module.ts
Normal file
27
packages/api/src/download/download.module.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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 {}
|
||||||
516
packages/api/src/download/download.service.ts
Normal file
516
packages/api/src/download/download.service.ts
Normal file
|
|
@ -0,0 +1,516 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/api/src/download/dto/create-download.dto.ts
Normal file
12
packages/api/src/download/dto/create-download.dto.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
311
packages/api/src/download/services/exif.service.ts
Normal file
311
packages/api/src/download/services/exif.service.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
329
packages/api/src/download/services/zip.service.ts
Normal file
329
packages/api/src/download/services/zip.service.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
166
packages/api/src/images/dto/image-response.dto.ts
Normal file
166
packages/api/src/images/dto/image-response.dto.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
43
packages/api/src/images/dto/update-filename.dto.ts
Normal file
43
packages/api/src/images/dto/update-filename.dto.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
349
packages/api/src/images/image.entity.ts
Normal file
349
packages/api/src/images/image.entity.ts
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
304
packages/api/src/images/images.controller.ts
Normal file
304
packages/api/src/images/images.controller.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/api/src/images/images.module.ts
Normal file
13
packages/api/src/images/images.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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 {}
|
||||||
442
packages/api/src/images/images.service.ts
Normal file
442
packages/api/src/images/images.service.ts
Normal file
|
|
@ -0,0 +1,442 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
packages/api/src/keywords/dto/enhance-keywords.dto.ts
Normal file
79
packages/api/src/keywords/dto/enhance-keywords.dto.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
192
packages/api/src/keywords/keywords.controller.ts
Normal file
192
packages/api/src/keywords/keywords.controller.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/api/src/keywords/keywords.module.ts
Normal file
12
packages/api/src/keywords/keywords.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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 {}
|
||||||
345
packages/api/src/keywords/keywords.service.ts
Normal file
345
packages/api/src/keywords/keywords.service.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
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: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
105
packages/api/src/main.ts
Normal file
105
packages/api/src/main.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
44
packages/api/src/monitoring/monitoring.module.ts
Normal file
44
packages/api/src/monitoring/monitoring.module.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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 {}
|
||||||
282
packages/api/src/monitoring/services/metrics.service.ts
Normal file
282
packages/api/src/monitoring/services/metrics.service.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/api/src/payments/dto/create-checkout-session.dto.ts
Normal file
30
packages/api/src/payments/dto/create-checkout-session.dto.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
12
packages/api/src/payments/dto/create-portal-session.dto.ts
Normal file
12
packages/api/src/payments/dto/create-portal-session.dto.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
344
packages/api/src/payments/payment.entity.ts
Normal file
344
packages/api/src/payments/payment.entity.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
297
packages/api/src/payments/payments.controller.ts
Normal file
297
packages/api/src/payments/payments.controller.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
packages/api/src/payments/payments.module.ts
Normal file
28
packages/api/src/payments/payments.module.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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 {}
|
||||||
292
packages/api/src/payments/payments.service.spec.ts
Normal file
292
packages/api/src/payments/payments.service.spec.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
390
packages/api/src/payments/payments.service.ts
Normal file
390
packages/api/src/payments/payments.service.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
318
packages/api/src/payments/services/stripe.service.ts
Normal file
318
packages/api/src/payments/services/stripe.service.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
393
packages/api/src/payments/services/subscription.service.ts
Normal file
393
packages/api/src/payments/services/subscription.service.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
280
packages/api/src/payments/services/webhook.service.ts
Normal file
280
packages/api/src/payments/services/webhook.service.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
249
packages/api/src/queue/processors/batch-processing.processor.ts
Normal file
249
packages/api/src/queue/processors/batch-processing.processor.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
packages/api/src/queue/processors/image-processing.processor.ts
Normal file
200
packages/api/src/queue/processors/image-processing.processor.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
packages/api/src/queue/queue.module.ts
Normal file
61
packages/api/src/queue/queue.module.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
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 {}
|
||||||
263
packages/api/src/queue/queue.service.ts
Normal file
263
packages/api/src/queue/queue.service.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/api/src/storage/storage.module.ts
Normal file
10
packages/api/src/storage/storage.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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 {}
|
||||||
263
packages/api/src/storage/storage.service.ts
Normal file
263
packages/api/src/storage/storage.service.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/api/src/upload/upload.module.ts
Normal file
10
packages/api/src/upload/upload.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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 {}
|
||||||
319
packages/api/src/upload/upload.service.ts
Normal file
319
packages/api/src/upload/upload.service.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
230
packages/api/src/users/users.controller.ts
Normal file
230
packages/api/src/users/users.controller.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
203
packages/api/src/users/users.entity.ts
Normal file
203
packages/api/src/users/users.entity.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
12
packages/api/src/users/users.module.ts
Normal file
12
packages/api/src/users/users.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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 {}
|
||||||
209
packages/api/src/users/users.service.ts
Normal file
209
packages/api/src/users/users.service.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
356
packages/api/src/websocket/progress.gateway.ts
Normal file
356
packages/api/src/websocket/progress.gateway.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/api/src/websocket/websocket.module.ts
Normal file
10
packages/api/src/websocket/websocket.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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 {}
|
||||||
47
packages/api/tsconfig.json
Normal file
47
packages/api/tsconfig.json
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
298
packages/frontend/api.js
Normal file
298
packages/frontend/api.js
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
195
packages/frontend/config.js
Normal file
195
packages/frontend/config.js
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
476
packages/frontend/index.html
Normal file
476
packages/frontend/index.html
Normal file
|
|
@ -0,0 +1,476 @@
|
||||||
|
<!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
Normal file
112
tsconfig.json
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
{
|
||||||
|
"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