Merge branch 'feature/database-schema' into feature/production-complete

This commit is contained in:
DustyWalker 2025-08-05 17:46:53 +02:00
commit f3870f56c9
15 changed files with 3606 additions and 0 deletions

51
packages/api/.env.example Normal file
View file

@ -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

98
packages/api/package.json Normal file
View file

@ -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"
}
}

View 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
View 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();
});

View 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;
}

View 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 {}

View 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);
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View file

@ -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<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;
}
}
/**
* 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);
}
}

View 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}`;
}

View 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);
}

View 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;
}

View 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"
]
}