diff --git a/packages/api/.env.example b/packages/api/.env.example new file mode 100644 index 0000000..8bb6580 --- /dev/null +++ b/packages/api/.env.example @@ -0,0 +1,51 @@ +# Database +DATABASE_URL="postgresql://username:password@localhost:5432/seo_image_renamer?schema=public" + +# Application +NODE_ENV="development" +PORT=3001 +API_PREFIX="api/v1" + +# JWT Configuration +JWT_SECRET="your-super-secret-jwt-key-here" +JWT_EXPIRES_IN="7d" + +# Google OAuth +GOOGLE_CLIENT_ID="your-google-client-id" +GOOGLE_CLIENT_SECRET="your-google-client-secret" +GOOGLE_REDIRECT_URI="http://localhost:3001/api/v1/auth/google/callback" + +# Stripe Configuration +STRIPE_SECRET_KEY="sk_test_your_stripe_secret_key" +STRIPE_PUBLISHABLE_KEY="pk_test_your_stripe_publishable_key" +STRIPE_WEBHOOK_SECRET="whsec_your_stripe_webhook_secret" + +# AWS S3 Configuration +AWS_ACCESS_KEY_ID="your-aws-access-key" +AWS_SECRET_ACCESS_KEY="your-aws-secret-key" +AWS_REGION="us-east-1" +AWS_S3_BUCKET="seo-image-renamer-uploads" + +# OpenAI Configuration +OPENAI_API_KEY="sk-your-openai-api-key" +OPENAI_MODEL="gpt-4-vision-preview" + +# Frontend URL (for CORS) +FRONTEND_URL="http://localhost:3000" + +# Redis (for caching and queues) +REDIS_URL="redis://localhost:6379" + +# Email Configuration (optional) +SMTP_HOST="smtp.gmail.com" +SMTP_PORT=587 +SMTP_USER="your-email@gmail.com" +SMTP_PASS="your-email-password" +FROM_EMAIL="noreply@seo-image-renamer.com" + +# Monitoring (optional) +SENTRY_DSN="https://your-sentry-dsn" + +# Rate Limiting +RATE_LIMIT_TTL=60 +RATE_LIMIT_LIMIT=10 \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..f36a1ef --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,98 @@ +{ + "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", + "@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" + }, + "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", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.1", + "typescript": "^5.1.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + } +} \ No newline at end of file diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma new file mode 100644 index 0000000..722d172 --- /dev/null +++ b/packages/api/prisma/schema.prisma @@ -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]) +} \ No newline at end of file diff --git a/packages/api/prisma/seed.ts b/packages/api/prisma/seed.ts new file mode 100644 index 0000000..0b359c7 --- /dev/null +++ b/packages/api/prisma/seed.ts @@ -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(); + }); \ No newline at end of file diff --git a/packages/api/src/batches/batch.entity.ts b/packages/api/src/batches/batch.entity.ts new file mode 100644 index 0000000..13dcf52 --- /dev/null +++ b/packages/api/src/batches/batch.entity.ts @@ -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; +} + +export class UpdateBatchDto { + @ApiPropertyOptional({ + description: 'Batch processing status', + enum: BatchStatus + }) + @IsOptional() + @IsEnum(BatchStatus) + status?: BatchStatus; + + @ApiPropertyOptional({ + description: 'Total number of images in this batch', + minimum: 0 + }) + @IsOptional() + @IsInt() + @Min(0) + totalImages?: number; + + @ApiPropertyOptional({ + description: 'Number of processed images', + minimum: 0 + }) + @IsOptional() + @IsInt() + @Min(0) + processedImages?: number; + + @ApiPropertyOptional({ + description: 'Number of failed images', + minimum: 0 + }) + @IsOptional() + @IsInt() + @Min(0) + failedImages?: number; + + @ApiPropertyOptional({ + description: 'Additional metadata for the batch processing' + }) + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class BatchResponseDto { + @ApiProperty({ + description: 'Unique batch identifier', + example: '550e8400-e29b-41d4-a716-446655440000' + }) + @IsUUID() + id: string; + + @ApiProperty({ + description: 'ID of the user who owns this batch', + example: '550e8400-e29b-41d4-a716-446655440000' + }) + @IsUUID() + userId: string; + + @ApiProperty({ + description: 'Current batch processing status', + enum: BatchStatus + }) + @IsEnum(BatchStatus) + status: BatchStatus; + + @ApiProperty({ + description: 'Total number of images in this batch', + example: 10 + }) + @IsInt() + @Min(0) + totalImages: number; + + @ApiProperty({ + description: 'Number of processed images', + example: 8 + }) + @IsInt() + @Min(0) + processedImages: number; + + @ApiProperty({ + description: 'Number of failed images', + example: 1 + }) + @IsInt() + @Min(0) + failedImages: number; + + @ApiPropertyOptional({ + description: 'Additional metadata for the batch processing' + }) + @IsOptional() + @IsObject() + metadata?: Record; + + @ApiProperty({ + description: 'Batch creation timestamp' + }) + @IsDate() + createdAt: Date; + + @ApiProperty({ + description: 'Batch last update timestamp' + }) + @IsDate() + updatedAt: Date; + + @ApiPropertyOptional({ + description: 'Batch completion timestamp' + }) + @IsOptional() + @IsDate() + completedAt?: Date; +} + +export class BatchStatsDto { + @ApiProperty({ + description: 'Processing progress percentage', + example: 80 + }) + @IsInt() + @Min(0) + progressPercentage: number; + + @ApiProperty({ + description: 'Number of pending images', + example: 1 + }) + @IsInt() + @Min(0) + pendingImages: number; + + @ApiProperty({ + description: 'Average processing time per image in seconds', + example: 5.2 + }) + @Type(() => Number) + averageProcessingTime: number; + + @ApiProperty({ + description: 'Estimated time remaining in seconds', + example: 30 + }) + @Type(() => Number) + estimatedTimeRemaining: number; +} + +export class BatchSummaryDto { + @ApiProperty({ + description: 'Batch details' + }) + batch: BatchResponseDto; + + @ApiProperty({ + description: 'Processing statistics' + }) + stats: BatchStatsDto; + + @ApiProperty({ + description: 'Recent images from this batch (limited to 5)' + }) + recentImages: Array<{ + id: string; + originalName: string; + proposedName?: string; + status: string; + }>; +} + +// Helper function to calculate progress percentage +export function calculateProgressPercentage(processedImages: number, totalImages: number): number { + if (totalImages === 0) return 0; + return Math.round((processedImages / totalImages) * 100); +} + +// Helper function to determine if batch is complete +export function isBatchComplete(batch: { status: BatchStatus; processedImages: number; failedImages: number; totalImages: number }): boolean { + return batch.status === BatchStatus.DONE || + batch.status === BatchStatus.ERROR || + (batch.processedImages + batch.failedImages) >= batch.totalImages; +} \ No newline at end of file diff --git a/packages/api/src/database/database.module.ts b/packages/api/src/database/database.module.ts new file mode 100644 index 0000000..fe3cea6 --- /dev/null +++ b/packages/api/src/database/database.module.ts @@ -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 {} \ No newline at end of file diff --git a/packages/api/src/database/prisma.service.ts b/packages/api/src/database/prisma.service.ts new file mode 100644 index 0000000..94a5e24 --- /dev/null +++ b/packages/api/src/database/prisma.service.ts @@ -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('DATABASE_URL'), + }, + }, + log: [ + { + emit: 'event', + level: 'query', + }, + { + emit: 'event', + level: 'error', + }, + { + emit: 'event', + level: 'info', + }, + { + emit: 'event', + level: 'warn', + }, + ], + errorFormat: 'colorless', + }); + + // Log database queries in development + if (configService.get('NODE_ENV') === 'development') { + this.$on('query', (e) => { + this.logger.debug(`Query: ${e.query}`); + this.logger.debug(`Params: ${e.params}`); + this.logger.debug(`Duration: ${e.duration}ms`); + }); + } + + // Log database errors + this.$on('error', (e) => { + this.logger.error('Database error:', e); + }); + + // Log database info + this.$on('info', (e) => { + this.logger.log(`Database info: ${e.message}`); + }); + + // Log database warnings + this.$on('warn', (e) => { + this.logger.warn(`Database warning: ${e.message}`); + }); + } + + async onModuleInit() { + try { + await this.$connect(); + this.logger.log('Successfully connected to database'); + + // Test the connection + await this.$queryRaw`SELECT 1`; + this.logger.log('Database connection test passed'); + } catch (error) { + this.logger.error('Failed to connect to database:', error); + throw error; + } + } + + async onModuleDestroy() { + try { + await this.$disconnect(); + this.logger.log('Disconnected from database'); + } catch (error) { + this.logger.error('Error during database disconnection:', error); + } + } + + /** + * Clean shutdown method for graceful application termination + */ + async enableShutdownHooks() { + process.on('beforeExit', async () => { + await this.$disconnect(); + }); + } + + /** + * Health check method to verify database connectivity + */ + async healthCheck(): Promise { + try { + await this.$queryRaw`SELECT 1`; + return true; + } catch (error) { + this.logger.error('Database health check failed:', error); + return false; + } + } + + /** + * Get database statistics + */ + async getDatabaseStats() { + try { + const [userCount, batchCount, imageCount, paymentCount] = await Promise.all([ + this.user.count(), + this.batch.count(), + this.image.count(), + this.payment.count(), + ]); + + return { + users: userCount, + batches: batchCount, + images: imageCount, + payments: paymentCount, + timestamp: new Date(), + }; + } catch (error) { + this.logger.error('Failed to get database stats:', error); + throw error; + } + } + + /** + * Transaction helper method + */ + async transaction(fn: (prisma: PrismaClient) => Promise): Promise { + return this.$transaction(fn); + } +} \ No newline at end of file diff --git a/packages/api/src/database/repositories/batch.repository.ts b/packages/api/src/database/repositories/batch.repository.ts new file mode 100644 index 0000000..95ba4a2 --- /dev/null +++ b/packages/api/src/database/repositories/batch.repository.ts @@ -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 { + try { + return await this.prisma.batch.create({ + data: { + ...data, + status: BatchStatus.PROCESSING, + }, + }); + } catch (error) { + this.logger.error('Failed to create batch:', error); + throw error; + } + } + + /** + * Find batch by ID + */ + async findById(id: string): Promise { + try { + return await this.prisma.batch.findUnique({ + where: { id }, + }); + } catch (error) { + this.logger.error(`Failed to find batch by ID ${id}:`, error); + throw error; + } + } + + /** + * Update batch + */ + async update(id: string, data: UpdateBatchDto): Promise { + try { + const updateData: any = { ...data }; + + // Set completedAt if status is changing to DONE or ERROR + if (data.status && (data.status === BatchStatus.DONE || data.status === BatchStatus.ERROR)) { + updateData.completedAt = new Date(); + } + + return await this.prisma.batch.update({ + where: { id }, + data: updateData, + }); + } catch (error) { + this.logger.error(`Failed to update batch ${id}:`, error); + throw error; + } + } + + /** + * Delete batch + */ + async delete(id: string): Promise { + try { + return await this.prisma.batch.delete({ + where: { id }, + }); + } catch (error) { + this.logger.error(`Failed to delete batch ${id}:`, error); + throw error; + } + } + + /** + * Find batches with pagination + */ + async findMany(params: { + skip?: number; + take?: number; + where?: Prisma.BatchWhereInput; + orderBy?: Prisma.BatchOrderByWithRelationInput; + }): Promise { + try { + return await this.prisma.batch.findMany({ + skip: params.skip, + take: params.take, + where: params.where, + orderBy: params.orderBy, + }); + } catch (error) { + this.logger.error('Failed to find batches:', error); + throw error; + } + } + + /** + * Find batches by user ID + */ + async findByUserId( + userId: string, + params?: { + skip?: number; + take?: number; + status?: BatchStatus; + orderBy?: Prisma.BatchOrderByWithRelationInput; + } + ): Promise { + try { + return await this.prisma.batch.findMany({ + where: { + userId, + ...(params?.status && { status: params.status }), + }, + skip: params?.skip, + take: params?.take, + orderBy: params?.orderBy || { createdAt: 'desc' }, + }); + } catch (error) { + this.logger.error(`Failed to find batches for user ${userId}:`, error); + throw error; + } + } + + /** + * Count batches + */ + async count(where?: Prisma.BatchWhereInput): Promise { + try { + return await this.prisma.batch.count({ where }); + } catch (error) { + this.logger.error('Failed to count batches:', error); + throw error; + } + } + + /** + * Find batch with images + */ + async findByIdWithImages(id: string): Promise { + try { + return await this.prisma.batch.findUnique({ + where: { id }, + include: { + images: { + orderBy: { createdAt: 'asc' }, + }, + user: { + select: { + id: true, + email: true, + plan: true, + }, + }, + _count: { + select: { images: true }, + }, + }, + }); + } catch (error) { + this.logger.error(`Failed to find batch with images ${id}:`, error); + throw error; + } + } + + /** + * Update batch progress + */ + async updateProgress(id: string, processedImages: number, failedImages: number): Promise { + try { + const batch = await this.findById(id); + if (!batch) { + throw new Error(`Batch ${id} not found`); + } + + // Determine if batch is complete + const totalProcessed = processedImages + failedImages; + const isComplete = totalProcessed >= batch.totalImages; + + const updateData: any = { + processedImages, + failedImages, + }; + + if (isComplete) { + updateData.status = failedImages === batch.totalImages ? BatchStatus.ERROR : BatchStatus.DONE; + updateData.completedAt = new Date(); + } + + return await this.prisma.batch.update({ + where: { id }, + data: updateData, + }); + } catch (error) { + this.logger.error(`Failed to update batch progress ${id}:`, error); + throw error; + } + } + + /** + * Increment processed images count + */ + async incrementProcessedImages(id: string): Promise { + try { + return await this.prisma.batch.update({ + where: { id }, + data: { + processedImages: { increment: 1 }, + }, + }); + } catch (error) { + this.logger.error(`Failed to increment processed images for batch ${id}:`, error); + throw error; + } + } + + /** + * Increment failed images count + */ + async incrementFailedImages(id: string): Promise { + try { + return await this.prisma.batch.update({ + where: { id }, + data: { + failedImages: { increment: 1 }, + }, + }); + } catch (error) { + this.logger.error(`Failed to increment failed images for batch ${id}:`, error); + throw error; + } + } + + /** + * Find processing batches (for cleanup/monitoring) + */ + async findProcessingBatches(olderThanMinutes?: number): Promise { + try { + const where: Prisma.BatchWhereInput = { + status: BatchStatus.PROCESSING, + }; + + if (olderThanMinutes) { + const cutoffTime = new Date(); + cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes); + where.createdAt = { lte: cutoffTime }; + } + + return await this.prisma.batch.findMany({ + where, + orderBy: { createdAt: 'asc' }, + }); + } catch (error) { + this.logger.error('Failed to find processing batches:', error); + throw error; + } + } + + /** + * Get batch statistics + */ + async getBatchStats(batchId: string): Promise<{ + totalImages: number; + processedImages: number; + failedImages: number; + pendingImages: number; + progressPercentage: number; + averageProcessingTime?: number; + }> { + try { + const batch = await this.findByIdWithImages(batchId); + if (!batch) { + throw new Error(`Batch ${batchId} not found`); + } + + const pendingImages = batch.totalImages - batch.processedImages - batch.failedImages; + const progressPercentage = Math.round( + ((batch.processedImages + batch.failedImages) / batch.totalImages) * 100 + ); + + // Calculate average processing time from completed images + const completedImages = batch.images.filter(img => img.processedAt); + let averageProcessingTime: number | undefined; + + if (completedImages.length > 0) { + const totalProcessingTime = completedImages.reduce((sum, img) => { + const processingTime = img.processedAt.getTime() - img.createdAt.getTime(); + return sum + processingTime; + }, 0); + averageProcessingTime = totalProcessingTime / completedImages.length / 1000; // Convert to seconds + } + + return { + totalImages: batch.totalImages, + processedImages: batch.processedImages, + failedImages: batch.failedImages, + pendingImages, + progressPercentage, + averageProcessingTime, + }; + } catch (error) { + this.logger.error(`Failed to get batch stats for ${batchId}:`, error); + throw error; + } + } + + /** + * Get user batch statistics + */ + async getUserBatchStats(userId: string): Promise<{ + totalBatches: number; + completedBatches: number; + processingBatches: number; + errorBatches: number; + totalImages: number; + }> { + try { + const [totalBatches, completedBatches, processingBatches, errorBatches, imageStats] = await Promise.all([ + this.count({ userId }), + this.count({ userId, status: BatchStatus.DONE }), + this.count({ userId, status: BatchStatus.PROCESSING }), + this.count({ userId, status: BatchStatus.ERROR }), + this.prisma.batch.aggregate({ + where: { userId }, + _sum: { totalImages: true }, + }), + ]); + + return { + totalBatches, + completedBatches, + processingBatches, + errorBatches, + totalImages: imageStats._sum.totalImages || 0, + }; + } catch (error) { + this.logger.error(`Failed to get user batch stats for ${userId}:`, error); + throw error; + } + } +} \ No newline at end of file diff --git a/packages/api/src/database/repositories/image.repository.ts b/packages/api/src/database/repositories/image.repository.ts new file mode 100644 index 0000000..88814a9 --- /dev/null +++ b/packages/api/src/database/repositories/image.repository.ts @@ -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 { + try { + return await this.prisma.image.create({ + data: { + ...data, + status: ImageStatus.PENDING, + }, + }); + } catch (error) { + this.logger.error('Failed to create image:', error); + throw error; + } + } + + /** + * Create multiple images in batch + */ + async createMany(images: CreateImageDto[]): Promise<{ count: number }> { + try { + const data = images.map(img => ({ + ...img, + status: ImageStatus.PENDING, + })); + + return await this.prisma.image.createMany({ + data, + skipDuplicates: true, + }); + } catch (error) { + this.logger.error('Failed to create multiple images:', error); + throw error; + } + } + + /** + * Find image by ID + */ + async findById(id: string): Promise { + try { + return await this.prisma.image.findUnique({ + where: { id }, + }); + } catch (error) { + this.logger.error(`Failed to find image by ID ${id}:`, error); + throw error; + } + } + + /** + * Update image + */ + async update(id: string, data: UpdateImageDto): Promise { + try { + const updateData: any = { ...data }; + + // Set processedAt if status is changing to COMPLETED or FAILED + if (data.status && (data.status === ImageStatus.COMPLETED || data.status === ImageStatus.FAILED)) { + updateData.processedAt = new Date(); + } + + return await this.prisma.image.update({ + where: { id }, + data: updateData, + }); + } catch (error) { + this.logger.error(`Failed to update image ${id}:`, error); + throw error; + } + } + + /** + * Delete image + */ + async delete(id: string): Promise { + try { + return await this.prisma.image.delete({ + where: { id }, + }); + } catch (error) { + this.logger.error(`Failed to delete image ${id}:`, error); + throw error; + } + } + + /** + * Find images with pagination + */ + async findMany(params: { + skip?: number; + take?: number; + where?: Prisma.ImageWhereInput; + orderBy?: Prisma.ImageOrderByWithRelationInput; + }): Promise { + try { + return await this.prisma.image.findMany({ + skip: params.skip, + take: params.take, + where: params.where, + orderBy: params.orderBy, + }); + } catch (error) { + this.logger.error('Failed to find images:', error); + throw error; + } + } + + /** + * Find images by batch ID + */ + async findByBatchId( + batchId: string, + params?: { + skip?: number; + take?: number; + status?: ImageStatus; + orderBy?: Prisma.ImageOrderByWithRelationInput; + } + ): Promise { + try { + return await this.prisma.image.findMany({ + where: { + batchId, + ...(params?.status && { status: params.status }), + }, + skip: params?.skip, + take: params?.take, + orderBy: params?.orderBy || { createdAt: 'asc' }, + }); + } catch (error) { + this.logger.error(`Failed to find images for batch ${batchId}:`, error); + throw error; + } + } + + /** + * Count images + */ + async count(where?: Prisma.ImageWhereInput): Promise { + try { + return await this.prisma.image.count({ where }); + } catch (error) { + this.logger.error('Failed to count images:', error); + throw error; + } + } + + /** + * Find image with batch info + */ + async findByIdWithBatch(id: string): Promise { + try { + return await this.prisma.image.findUnique({ + where: { id }, + include: { + batch: { + include: { + user: { + select: { + id: true, + email: true, + plan: true, + }, + }, + }, + }, + }, + }); + } catch (error) { + this.logger.error(`Failed to find image with batch ${id}:`, error); + throw error; + } + } + + /** + * Update image status + */ + async updateStatus(id: string, status: ImageStatus, error?: string): Promise { + try { + const updateData: any = { + status, + ...(error && { processingError: error }), + }; + + if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) { + updateData.processedAt = new Date(); + } + + return await this.prisma.image.update({ + where: { id }, + data: updateData, + }); + } catch (error) { + this.logger.error(`Failed to update image status ${id}:`, error); + throw error; + } + } + + /** + * Bulk update image statuses + */ + async bulkUpdateStatus(imageIds: string[], status: ImageStatus): Promise<{ count: number }> { + try { + const updateData: any = { status }; + + if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) { + updateData.processedAt = new Date(); + } + + return await this.prisma.image.updateMany({ + where: { + id: { in: imageIds }, + }, + data: updateData, + }); + } catch (error) { + this.logger.error('Failed to bulk update image statuses:', error); + throw error; + } + } + + /** + * Apply proposed names as final names + */ + async applyProposedNames(imageIds: string[]): Promise<{ count: number }> { + try { + // First, get all images with their proposed names + const images = await this.prisma.image.findMany({ + where: { + id: { in: imageIds }, + proposedName: { not: null }, + }, + select: { id: true, proposedName: true }, + }); + + // Use transaction to update each image with its proposed name as final name + const results = await this.prisma.$transaction( + images.map(image => + this.prisma.image.update({ + where: { id: image.id }, + data: { finalName: image.proposedName }, + }) + ) + ); + + return { count: results.length }; + } catch (error) { + this.logger.error('Failed to apply proposed names:', error); + throw error; + } + } + + /** + * Find pending images for processing + */ + async findPendingImages(limit?: number): Promise { + try { + return await this.prisma.image.findMany({ + where: { + status: ImageStatus.PENDING, + }, + orderBy: { createdAt: 'asc' }, + take: limit, + include: { + batch: { + include: { + user: { + select: { + id: true, + email: true, + plan: true, + }, + }, + }, + }, + }, + }); + } catch (error) { + this.logger.error('Failed to find pending images:', error); + throw error; + } + } + + /** + * Find processing images (for cleanup/monitoring) + */ + async findProcessingImages(olderThanMinutes?: number): Promise { + try { + const where: Prisma.ImageWhereInput = { + status: ImageStatus.PROCESSING, + }; + + if (olderThanMinutes) { + const cutoffTime = new Date(); + cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes); + where.updatedAt = { lte: cutoffTime }; + } + + return await this.prisma.image.findMany({ + where, + orderBy: { updatedAt: 'asc' }, + }); + } catch (error) { + this.logger.error('Failed to find processing images:', error); + throw error; + } + } + + /** + * Get image processing statistics for a batch + */ + async getBatchImageStats(batchId: string): Promise<{ + total: number; + pending: number; + processing: number; + completed: number; + failed: number; + }> { + try { + const [total, pending, processing, completed, failed] = await Promise.all([ + this.count({ batchId }), + this.count({ batchId, status: ImageStatus.PENDING }), + this.count({ batchId, status: ImageStatus.PROCESSING }), + this.count({ batchId, status: ImageStatus.COMPLETED }), + this.count({ batchId, status: ImageStatus.FAILED }), + ]); + + return { + total, + pending, + processing, + completed, + failed, + }; + } catch (error) { + this.logger.error(`Failed to get batch image stats for ${batchId}:`, error); + throw error; + } + } + + /** + * Get user image processing statistics + */ + async getUserImageStats(userId: string): Promise<{ + totalImages: number; + completedImages: number; + failedImages: number; + processingImages: number; + pendingImages: number; + }> { + try { + const [ + totalImages, + completedImages, + failedImages, + processingImages, + pendingImages, + ] = await Promise.all([ + this.prisma.image.count({ + where: { + batch: { userId }, + }, + }), + this.prisma.image.count({ + where: { + batch: { userId }, + status: ImageStatus.COMPLETED, + }, + }), + this.prisma.image.count({ + where: { + batch: { userId }, + status: ImageStatus.FAILED, + }, + }), + this.prisma.image.count({ + where: { + batch: { userId }, + status: ImageStatus.PROCESSING, + }, + }), + this.prisma.image.count({ + where: { + batch: { userId }, + status: ImageStatus.PENDING, + }, + }), + ]); + + return { + totalImages, + completedImages, + failedImages, + processingImages, + pendingImages, + }; + } catch (error) { + this.logger.error(`Failed to get user image stats for ${userId}:`, error); + throw error; + } + } + + /** + * Search images by original name + */ + async searchByOriginalName( + searchTerm: string, + userId?: string, + params?: { skip?: number; take?: number } + ): Promise { + try { + const where: Prisma.ImageWhereInput = { + originalName: { + contains: searchTerm, + mode: 'insensitive', + }, + ...(userId && { + batch: { userId }, + }), + }; + + return await this.prisma.image.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: params?.skip, + take: params?.take, + include: { + batch: { + select: { + id: true, + status: true, + createdAt: true, + }, + }, + }, + }); + } catch (error) { + this.logger.error('Failed to search images by original name:', error); + throw error; + } + } +} \ No newline at end of file diff --git a/packages/api/src/database/repositories/payment.repository.ts b/packages/api/src/database/repositories/payment.repository.ts new file mode 100644 index 0000000..9568025 --- /dev/null +++ b/packages/api/src/database/repositories/payment.repository.ts @@ -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 { + try { + return await this.prisma.payment.create({ + data: { + ...data, + status: PaymentStatus.PENDING, + }, + }); + } catch (error) { + this.logger.error('Failed to create payment:', error); + throw error; + } + } + + /** + * Find payment by ID + */ + async findById(id: string): Promise { + try { + return await this.prisma.payment.findUnique({ + where: { id }, + }); + } catch (error) { + this.logger.error(`Failed to find payment by ID ${id}:`, error); + throw error; + } + } + + /** + * Find payment by Stripe Session ID + */ + async findByStripeSessionId(stripeSessionId: string): Promise { + try { + return await this.prisma.payment.findUnique({ + where: { stripeSessionId }, + }); + } catch (error) { + this.logger.error(`Failed to find payment by Stripe Session ID ${stripeSessionId}:`, error); + throw error; + } + } + + /** + * Find payment by Stripe Payment ID + */ + async findByStripePaymentId(stripePaymentId: string): Promise { + try { + return await this.prisma.payment.findUnique({ + where: { stripePaymentId }, + }); + } catch (error) { + this.logger.error(`Failed to find payment by Stripe Payment ID ${stripePaymentId}:`, error); + throw error; + } + } + + /** + * Update payment + */ + async update(id: string, data: UpdatePaymentDto): Promise { + try { + const updateData: any = { ...data }; + + // Set paidAt if status is changing to COMPLETED + if (data.status === PaymentStatus.COMPLETED) { + updateData.paidAt = new Date(); + } + + return await this.prisma.payment.update({ + where: { id }, + data: updateData, + }); + } catch (error) { + this.logger.error(`Failed to update payment ${id}:`, error); + throw error; + } + } + + /** + * Delete payment + */ + async delete(id: string): Promise { + try { + return await this.prisma.payment.delete({ + where: { id }, + }); + } catch (error) { + this.logger.error(`Failed to delete payment ${id}:`, error); + throw error; + } + } + + /** + * Find payments with pagination + */ + async findMany(params: { + skip?: number; + take?: number; + where?: Prisma.PaymentWhereInput; + orderBy?: Prisma.PaymentOrderByWithRelationInput; + }): Promise { + try { + return await this.prisma.payment.findMany({ + skip: params.skip, + take: params.take, + where: params.where, + orderBy: params.orderBy, + }); + } catch (error) { + this.logger.error('Failed to find payments:', error); + throw error; + } + } + + /** + * Find payments by user ID + */ + async findByUserId( + userId: string, + params?: { + skip?: number; + take?: number; + status?: PaymentStatus; + orderBy?: Prisma.PaymentOrderByWithRelationInput; + } + ): Promise { + try { + return await this.prisma.payment.findMany({ + where: { + userId, + ...(params?.status && { status: params.status }), + }, + skip: params?.skip, + take: params?.take, + orderBy: params?.orderBy || { createdAt: 'desc' }, + }); + } catch (error) { + this.logger.error(`Failed to find payments for user ${userId}:`, error); + throw error; + } + } + + /** + * Count payments + */ + async count(where?: Prisma.PaymentWhereInput): Promise { + try { + return await this.prisma.payment.count({ where }); + } catch (error) { + this.logger.error('Failed to count payments:', error); + throw error; + } + } + + /** + * Find payment with user info + */ + async findByIdWithUser(id: string): Promise { + try { + return await this.prisma.payment.findUnique({ + where: { id }, + include: { + user: { + select: { + id: true, + email: true, + plan: true, + quotaRemaining: true, + }, + }, + }, + }); + } catch (error) { + this.logger.error(`Failed to find payment with user ${id}:`, error); + throw error; + } + } + + /** + * Update payment status + */ + async updateStatus(id: string, status: PaymentStatus, stripePaymentId?: string): Promise { + try { + const updateData: any = { status }; + + if (stripePaymentId) { + updateData.stripePaymentId = stripePaymentId; + } + + if (status === PaymentStatus.COMPLETED) { + updateData.paidAt = new Date(); + } + + return await this.prisma.payment.update({ + where: { id }, + data: updateData, + }); + } catch (error) { + this.logger.error(`Failed to update payment status ${id}:`, error); + throw error; + } + } + + /** + * Find successful payments by user + */ + async findSuccessfulPaymentsByUserId(userId: string): Promise { + try { + return await this.prisma.payment.findMany({ + where: { + userId, + status: PaymentStatus.COMPLETED, + }, + orderBy: { paidAt: 'desc' }, + }); + } catch (error) { + this.logger.error(`Failed to find successful payments for user ${userId}:`, error); + throw error; + } + } + + /** + * Get user payment statistics + */ + async getUserPaymentStats(userId: string): Promise<{ + totalPayments: number; + successfulPayments: number; + failedPayments: number; + totalAmountSpent: number; + lastPaymentDate?: Date; + averagePaymentAmount: number; + }> { + try { + const [ + totalPayments, + successfulPayments, + failedPayments, + amountStats, + lastSuccessfulPayment, + ] = await Promise.all([ + this.count({ userId }), + this.count({ userId, status: PaymentStatus.COMPLETED }), + this.count({ + userId, + status: { in: [PaymentStatus.FAILED, PaymentStatus.CANCELLED] } + }), + this.prisma.payment.aggregate({ + where: { + userId, + status: PaymentStatus.COMPLETED + }, + _sum: { amount: true }, + _avg: { amount: true }, + }), + this.prisma.payment.findFirst({ + where: { + userId, + status: PaymentStatus.COMPLETED + }, + orderBy: { paidAt: 'desc' }, + select: { paidAt: true }, + }), + ]); + + return { + totalPayments, + successfulPayments, + failedPayments, + totalAmountSpent: amountStats._sum.amount || 0, + lastPaymentDate: lastSuccessfulPayment?.paidAt || undefined, + averagePaymentAmount: Math.round(amountStats._avg.amount || 0), + }; + } catch (error) { + this.logger.error(`Failed to get user payment stats for ${userId}:`, error); + throw error; + } + } + + /** + * Find pending payments (for cleanup/monitoring) + */ + async findPendingPayments(olderThanMinutes?: number): Promise { + try { + const where: Prisma.PaymentWhereInput = { + status: PaymentStatus.PENDING, + }; + + if (olderThanMinutes) { + const cutoffTime = new Date(); + cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes); + where.createdAt = { lte: cutoffTime }; + } + + return await this.prisma.payment.findMany({ + where, + orderBy: { createdAt: 'asc' }, + include: { + user: { + select: { + id: true, + email: true, + }, + }, + }, + }); + } catch (error) { + this.logger.error('Failed to find pending payments:', error); + throw error; + } + } + + /** + * Get revenue statistics + */ + async getRevenueStats(params?: { + startDate?: Date; + endDate?: Date; + plan?: Plan; + }): Promise<{ + totalRevenue: number; + totalPayments: number; + averagePaymentAmount: number; + revenueByPlan: Record; + paymentsCount: Record; + }> { + try { + const where: Prisma.PaymentWhereInput = { + status: PaymentStatus.COMPLETED, + ...(params?.startDate && { createdAt: { gte: params.startDate } }), + ...(params?.endDate && { createdAt: { lte: params.endDate } }), + ...(params?.plan && { plan: params.plan }), + }; + + const [revenueStats, revenueByPlan, paymentStatusCounts] = await Promise.all([ + this.prisma.payment.aggregate({ + where, + _sum: { amount: true }, + _count: true, + _avg: { amount: true }, + }), + this.prisma.payment.groupBy({ + by: ['plan'], + where, + _sum: { amount: true }, + }), + this.prisma.payment.groupBy({ + by: ['status'], + _count: true, + }), + ]); + + const revenueByPlanMap = Object.values(Plan).reduce((acc, plan) => { + acc[plan] = 0; + return acc; + }, {} as Record); + + revenueByPlan.forEach(item => { + revenueByPlanMap[item.plan] = item._sum.amount || 0; + }); + + const paymentsCountMap = Object.values(PaymentStatus).reduce((acc, status) => { + acc[status] = 0; + return acc; + }, {} as Record); + + paymentStatusCounts.forEach(item => { + paymentsCountMap[item.status] = item._count; + }); + + return { + totalRevenue: revenueStats._sum.amount || 0, + totalPayments: revenueStats._count, + averagePaymentAmount: Math.round(revenueStats._avg.amount || 0), + revenueByPlan: revenueByPlanMap, + paymentsCount: paymentsCountMap, + }; + } catch (error) { + this.logger.error('Failed to get revenue stats:', error); + throw error; + } + } + + /** + * Find payments by date range + */ + async findPaymentsByDateRange( + startDate: Date, + endDate: Date, + params?: { + userId?: string; + status?: PaymentStatus; + plan?: Plan; + } + ): Promise { + try { + return await this.prisma.payment.findMany({ + where: { + createdAt: { + gte: startDate, + lte: endDate, + }, + ...(params?.userId && { userId: params.userId }), + ...(params?.status && { status: params.status }), + ...(params?.plan && { plan: params.plan }), + }, + orderBy: { createdAt: 'desc' }, + include: { + user: { + select: { + id: true, + email: true, + }, + }, + }, + }); + } catch (error) { + this.logger.error('Failed to find payments by date range:', error); + throw error; + } + } +} \ No newline at end of file diff --git a/packages/api/src/database/repositories/user.repository.ts b/packages/api/src/database/repositories/user.repository.ts new file mode 100644 index 0000000..46991cc --- /dev/null +++ b/packages/api/src/database/repositories/user.repository.ts @@ -0,0 +1,309 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { User, Plan, Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma.service'; +import { CreateUserDto, UpdateUserDto } from '../../users/users.entity'; + +@Injectable() +export class UserRepository { + private readonly logger = new Logger(UserRepository.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Create a new user + */ + async create(data: CreateUserDto): Promise { + try { + return await this.prisma.user.create({ + data: { + ...data, + plan: data.plan || Plan.BASIC, + quotaRemaining: data.quotaRemaining || this.getQuotaForPlan(data.plan || Plan.BASIC), + }, + }); + } catch (error) { + this.logger.error('Failed to create user:', error); + throw error; + } + } + + /** + * Find user by ID + */ + async findById(id: string): Promise { + try { + return await this.prisma.user.findUnique({ + where: { id }, + }); + } catch (error) { + this.logger.error(`Failed to find user by ID ${id}:`, error); + throw error; + } + } + + /** + * Find user by email + */ + async findByEmail(email: string): Promise { + try { + return await this.prisma.user.findUnique({ + where: { email }, + }); + } catch (error) { + this.logger.error(`Failed to find user by email ${email}:`, error); + throw error; + } + } + + /** + * Find user by Google UID + */ + async findByGoogleUid(googleUid: string): Promise { + try { + return await this.prisma.user.findUnique({ + where: { googleUid }, + }); + } catch (error) { + this.logger.error(`Failed to find user by Google UID ${googleUid}:`, error); + throw error; + } + } + + /** + * Find user by email hash + */ + async findByEmailHash(emailHash: string): Promise { + try { + return await this.prisma.user.findUnique({ + where: { emailHash }, + }); + } catch (error) { + this.logger.error(`Failed to find user by email hash:`, error); + throw error; + } + } + + /** + * Update user + */ + async update(id: string, data: UpdateUserDto): Promise { + try { + return await this.prisma.user.update({ + where: { id }, + data, + }); + } catch (error) { + this.logger.error(`Failed to update user ${id}:`, error); + throw error; + } + } + + /** + * Delete user + */ + async delete(id: string): Promise { + try { + return await this.prisma.user.delete({ + where: { id }, + }); + } catch (error) { + this.logger.error(`Failed to delete user ${id}:`, error); + throw error; + } + } + + /** + * Find users with pagination + */ + async findMany(params: { + skip?: number; + take?: number; + where?: Prisma.UserWhereInput; + orderBy?: Prisma.UserOrderByWithRelationInput; + }): Promise { + try { + return await this.prisma.user.findMany({ + skip: params.skip, + take: params.take, + where: params.where, + orderBy: params.orderBy, + }); + } catch (error) { + this.logger.error('Failed to find users:', error); + throw error; + } + } + + /** + * Count users + */ + async count(where?: Prisma.UserWhereInput): Promise { + try { + return await this.prisma.user.count({ where }); + } catch (error) { + this.logger.error('Failed to count users:', error); + throw error; + } + } + + /** + * Update user quota + */ + async updateQuota(id: string, quotaRemaining: number): Promise { + try { + return await this.prisma.user.update({ + where: { id }, + data: { quotaRemaining }, + }); + } catch (error) { + this.logger.error(`Failed to update quota for user ${id}:`, error); + throw error; + } + } + + /** + * Deduct quota from user + */ + async deductQuota(id: string, amount: number): Promise { + try { + return await this.prisma.user.update({ + where: { id }, + data: { + quotaRemaining: { + decrement: amount, + }, + }, + }); + } catch (error) { + this.logger.error(`Failed to deduct quota for user ${id}:`, error); + throw error; + } + } + + /** + * Reset user quota (monthly reset) + */ + async resetQuota(id: string): Promise { + try { + const user = await this.findById(id); + if (!user) { + throw new Error(`User ${id} not found`); + } + + const newQuota = this.getQuotaForPlan(user.plan); + const nextResetDate = this.calculateNextResetDate(); + + return await this.prisma.user.update({ + where: { id }, + data: { + quotaRemaining: newQuota, + quotaResetDate: nextResetDate, + }, + }); + } catch (error) { + this.logger.error(`Failed to reset quota for user ${id}:`, error); + throw error; + } + } + + /** + * Upgrade user plan + */ + async upgradePlan(id: string, newPlan: Plan): Promise { + try { + const newQuota = this.getQuotaForPlan(newPlan); + + return await this.prisma.user.update({ + where: { id }, + data: { + plan: newPlan, + quotaRemaining: newQuota, + quotaResetDate: this.calculateNextResetDate(), + }, + }); + } catch (error) { + this.logger.error(`Failed to upgrade plan for user ${id}:`, error); + throw error; + } + } + + /** + * Find users with expired quotas + */ + async findUsersWithExpiredQuotas(): Promise { + try { + return await this.prisma.user.findMany({ + where: { + quotaResetDate: { + lte: new Date(), + }, + isActive: true, + }, + }); + } catch (error) { + this.logger.error('Failed to find users with expired quotas:', error); + throw error; + } + } + + /** + * Get user with related data + */ + async findByIdWithRelations(id: string): Promise { + try { + return await this.prisma.user.findUnique({ + where: { id }, + include: { + batches: { + orderBy: { createdAt: 'desc' }, + take: 5, + }, + payments: { + orderBy: { createdAt: 'desc' }, + take: 5, + }, + _count: { + select: { + batches: true, + payments: true, + }, + }, + }, + }); + } catch (error) { + this.logger.error(`Failed to find user with relations ${id}:`, error); + throw error; + } + } + + /** + * Helper: Get quota for plan + */ + private getQuotaForPlan(plan: Plan): number { + switch (plan) { + case Plan.BASIC: + return 50; + case Plan.PRO: + return 500; + case Plan.MAX: + return 1000; + default: + return 50; + } + } + + /** + * Helper: Calculate next quota reset date (first day of next month) + */ + private calculateNextResetDate(): Date { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth() + 1, 1); + } +} \ No newline at end of file diff --git a/packages/api/src/images/image.entity.ts b/packages/api/src/images/image.entity.ts new file mode 100644 index 0000000..6b06704 --- /dev/null +++ b/packages/api/src/images/image.entity.ts @@ -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}`; +} \ No newline at end of file diff --git a/packages/api/src/payments/payment.entity.ts b/packages/api/src/payments/payment.entity.ts new file mode 100644 index 0000000..1b9a9c8 --- /dev/null +++ b/packages/api/src/payments/payment.entity.ts @@ -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); +} \ No newline at end of file diff --git a/packages/api/src/users/users.entity.ts b/packages/api/src/users/users.entity.ts new file mode 100644 index 0000000..cff7320 --- /dev/null +++ b/packages/api/src/users/users.entity.ts @@ -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; +} \ No newline at end of file diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..febf7a2 --- /dev/null +++ b/packages/api/tsconfig.json @@ -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" + ] +} \ No newline at end of file