feat(db): implement complete database schema and models
- Add Prisma schema with PostgreSQL 15 support - Create Users, Batches, Images, Payments, ApiKeys tables - Implement proper foreign key relationships and indexes - Add enum types for status fields (Plan, BatchStatus, ImageStatus, PaymentStatus) - Support for JSON fields (vision_tags, metadata) - UUID primary keys for security - Created/updated timestamps with proper defaults Database Layer Components: - Prisma service with connection management and health checks - Repository pattern for all entities with comprehensive CRUD operations - TypeScript DTOs with class-validator decorations - Swagger API documentation annotations - Helper functions for business logic (quota management, pricing, etc.) Development Support: - Environment variables template - Database seed script with realistic test data - TypeScript configuration optimized for Nest.js - Package.json with all required dependencies Resolves database requirements from issues §78-81 establishing the complete data layer foundation for the AI Bulk Image Renamer SaaS. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
90016254a9
commit
e7e09d5e2c
15 changed files with 3606 additions and 0 deletions
51
packages/api/.env.example
Normal file
51
packages/api/.env.example
Normal 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
98
packages/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
179
packages/api/prisma/schema.prisma
Normal file
179
packages/api/prisma/schema.prisma
Normal file
|
@ -0,0 +1,179 @@
|
|||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// Enum for user subscription plans
|
||||
enum Plan {
|
||||
BASIC // 50 images per month
|
||||
PRO // 500 images per month
|
||||
MAX // 1000 images per month
|
||||
}
|
||||
|
||||
// Enum for batch processing status
|
||||
enum BatchStatus {
|
||||
PROCESSING
|
||||
DONE
|
||||
ERROR
|
||||
}
|
||||
|
||||
// Enum for individual image processing status
|
||||
enum ImageStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
// Enum for payment status
|
||||
enum PaymentStatus {
|
||||
PENDING
|
||||
COMPLETED
|
||||
FAILED
|
||||
CANCELLED
|
||||
REFUNDED
|
||||
}
|
||||
|
||||
// Users table - OAuth ready with Google integration
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
googleUid String? @unique @map("google_uid") // Google OAuth UID
|
||||
emailHash String @unique @map("email_hash") // Hashed email for privacy
|
||||
email String @unique // Actual email for communication
|
||||
plan Plan @default(BASIC)
|
||||
quotaRemaining Int @default(50) @map("quota_remaining") // Monthly quota
|
||||
quotaResetDate DateTime @default(now()) @map("quota_reset_date") // When quota resets
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
batches Batch[]
|
||||
payments Payment[]
|
||||
apiKeys ApiKey[]
|
||||
|
||||
@@map("users")
|
||||
@@index([emailHash])
|
||||
@@index([googleUid])
|
||||
@@index([plan])
|
||||
}
|
||||
|
||||
// Batches table - Groups of images processed together
|
||||
model Batch {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
status BatchStatus @default(PROCESSING)
|
||||
totalImages Int @default(0) @map("total_images")
|
||||
processedImages Int @default(0) @map("processed_images")
|
||||
failedImages Int @default(0) @map("failed_images")
|
||||
metadata Json? // Additional batch metadata (e.g., processing settings)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
images Image[]
|
||||
|
||||
@@map("batches")
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// Images table - Individual images within batches
|
||||
model Image {
|
||||
id String @id @default(uuid())
|
||||
batchId String @map("batch_id")
|
||||
originalName String @map("original_name")
|
||||
proposedName String? @map("proposed_name") // AI-generated name
|
||||
finalName String? @map("final_name") // User-approved final name
|
||||
visionTags Json? @map("vision_tags") // AI vision analysis results
|
||||
status ImageStatus @default(PENDING)
|
||||
fileSize Int? @map("file_size") // File size in bytes
|
||||
dimensions Json? // Width/height as JSON object
|
||||
mimeType String? @map("mime_type")
|
||||
s3Key String? @map("s3_key") // S3 object key for storage
|
||||
processingError String? @map("processing_error") // Error message if processing failed
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
processedAt DateTime? @map("processed_at")
|
||||
|
||||
// Relations
|
||||
batch Batch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("images")
|
||||
@@index([batchId])
|
||||
@@index([status])
|
||||
@@index([originalName])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// Payments table - Stripe integration for subscription management
|
||||
model Payment {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
stripeSessionId String? @unique @map("stripe_session_id") // Stripe Checkout Session ID
|
||||
stripePaymentId String? @unique @map("stripe_payment_id") // Stripe Payment Intent ID
|
||||
plan Plan // The plan being purchased
|
||||
amount Int // Amount in cents
|
||||
currency String @default("usd")
|
||||
status PaymentStatus @default(PENDING)
|
||||
metadata Json? // Additional payment metadata
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
paidAt DateTime? @map("paid_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("payments")
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([stripeSessionId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// API Keys table - For potential API access
|
||||
model ApiKey {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
keyHash String @unique @map("key_hash") // Hashed API key
|
||||
name String // User-friendly name for the key
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
lastUsed DateTime? @map("last_used")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
usage ApiKeyUsage[]
|
||||
|
||||
@@map("api_keys")
|
||||
@@index([userId])
|
||||
@@index([keyHash])
|
||||
@@index([isActive])
|
||||
}
|
||||
|
||||
// API Key Usage tracking
|
||||
model ApiKeyUsage {
|
||||
id String @id @default(uuid())
|
||||
apiKeyId String @map("api_key_id")
|
||||
endpoint String // Which API endpoint was called
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("api_key_usage")
|
||||
@@index([apiKeyId])
|
||||
@@index([createdAt])
|
||||
}
|
391
packages/api/prisma/seed.ts
Normal file
391
packages/api/prisma/seed.ts
Normal file
|
@ -0,0 +1,391 @@
|
|||
import { PrismaClient, Plan, BatchStatus, ImageStatus, PaymentStatus } from '@prisma/client';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Starting database seed...');
|
||||
|
||||
// Create test users
|
||||
const users = await Promise.all([
|
||||
prisma.user.create({
|
||||
data: {
|
||||
googleUid: 'google_test_user_1',
|
||||
email: 'john.doe@example.com',
|
||||
emailHash: crypto.createHash('sha256').update('john.doe@example.com').digest('hex'),
|
||||
plan: Plan.BASIC,
|
||||
quotaRemaining: 50,
|
||||
quotaResetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
},
|
||||
}),
|
||||
prisma.user.create({
|
||||
data: {
|
||||
googleUid: 'google_test_user_2',
|
||||
email: 'jane.smith@example.com',
|
||||
emailHash: crypto.createHash('sha256').update('jane.smith@example.com').digest('hex'),
|
||||
plan: Plan.PRO,
|
||||
quotaRemaining: 450,
|
||||
quotaResetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
}),
|
||||
prisma.user.create({
|
||||
data: {
|
||||
googleUid: 'google_test_user_3',
|
||||
email: 'bob.wilson@example.com',
|
||||
emailHash: crypto.createHash('sha256').update('bob.wilson@example.com').digest('hex'),
|
||||
plan: Plan.MAX,
|
||||
quotaRemaining: 900,
|
||||
quotaResetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log(`✅ Created ${users.length} test users`);
|
||||
|
||||
// Create test batches
|
||||
const batches = [];
|
||||
|
||||
// Completed batch for first user
|
||||
const completedBatch = await prisma.batch.create({
|
||||
data: {
|
||||
userId: users[0].id,
|
||||
status: BatchStatus.DONE,
|
||||
totalImages: 5,
|
||||
processedImages: 4,
|
||||
failedImages: 1,
|
||||
completedAt: new Date(),
|
||||
metadata: {
|
||||
processingOptions: {
|
||||
includeColors: true,
|
||||
includeTags: true,
|
||||
aiModel: 'gpt-4-vision',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
batches.push(completedBatch);
|
||||
|
||||
// Processing batch for second user
|
||||
const processingBatch = await prisma.batch.create({
|
||||
data: {
|
||||
userId: users[1].id,
|
||||
status: BatchStatus.PROCESSING,
|
||||
totalImages: 10,
|
||||
processedImages: 6,
|
||||
failedImages: 1,
|
||||
metadata: {
|
||||
processingOptions: {
|
||||
includeColors: true,
|
||||
includeTags: true,
|
||||
includeScene: true,
|
||||
aiModel: 'gpt-4-vision',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
batches.push(processingBatch);
|
||||
|
||||
// Error batch for third user
|
||||
const errorBatch = await prisma.batch.create({
|
||||
data: {
|
||||
userId: users[2].id,
|
||||
status: BatchStatus.ERROR,
|
||||
totalImages: 3,
|
||||
processedImages: 0,
|
||||
failedImages: 3,
|
||||
completedAt: new Date(),
|
||||
metadata: {
|
||||
error: 'Invalid image format detected',
|
||||
},
|
||||
},
|
||||
});
|
||||
batches.push(errorBatch);
|
||||
|
||||
console.log(`✅ Created ${batches.length} test batches`);
|
||||
|
||||
// Create test images for completed batch
|
||||
const completedBatchImages = await Promise.all([
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: completedBatch.id,
|
||||
originalName: 'IMG_20240101_123456.jpg',
|
||||
proposedName: 'modern-kitchen-with-stainless-steel-appliances.jpg',
|
||||
finalName: 'kitchen-renovation-final.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 2048576,
|
||||
mimeType: 'image/jpeg',
|
||||
dimensions: { width: 1920, height: 1080, aspectRatio: '16:9' },
|
||||
visionTags: {
|
||||
objects: ['kitchen', 'refrigerator', 'countertop', 'cabinets'],
|
||||
colors: ['white', 'stainless steel', 'black'],
|
||||
scene: 'modern kitchen interior',
|
||||
description: 'A modern kitchen with stainless steel appliances and white cabinets',
|
||||
confidence: 0.95,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 2.5,
|
||||
},
|
||||
s3Key: 'uploads/user1/batch1/IMG_20240101_123456.jpg',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: completedBatch.id,
|
||||
originalName: 'DSC_0001.jpg',
|
||||
proposedName: 'cozy-living-room-with-fireplace.jpg',
|
||||
finalName: 'living-room-cozy-fireplace.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 3145728,
|
||||
mimeType: 'image/jpeg',
|
||||
dimensions: { width: 2560, height: 1440, aspectRatio: '16:9' },
|
||||
visionTags: {
|
||||
objects: ['fireplace', 'sofa', 'coffee table', 'lamp'],
|
||||
colors: ['brown', 'cream', 'orange'],
|
||||
scene: 'cozy living room',
|
||||
description: 'A cozy living room with a warm fireplace and comfortable seating',
|
||||
confidence: 0.92,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 3.1,
|
||||
},
|
||||
s3Key: 'uploads/user1/batch1/DSC_0001.jpg',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: completedBatch.id,
|
||||
originalName: 'photo_2024_01_01.png',
|
||||
proposedName: 'elegant-bedroom-with-natural-light.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 1572864,
|
||||
mimeType: 'image/png',
|
||||
dimensions: { width: 1600, height: 900, aspectRatio: '16:9' },
|
||||
visionTags: {
|
||||
objects: ['bed', 'window', 'curtains', 'nightstand'],
|
||||
colors: ['white', 'beige', 'natural'],
|
||||
scene: 'elegant bedroom',
|
||||
description: 'An elegant bedroom with natural light streaming through large windows',
|
||||
confidence: 0.88,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 2.8,
|
||||
},
|
||||
s3Key: 'uploads/user1/batch1/photo_2024_01_01.png',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: completedBatch.id,
|
||||
originalName: 'bathroom_pic.jpg',
|
||||
proposedName: 'luxury-bathroom-with-marble-tiles.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 2621440,
|
||||
mimeType: 'image/jpeg',
|
||||
dimensions: { width: 1920, height: 1080, aspectRatio: '16:9' },
|
||||
visionTags: {
|
||||
objects: ['bathroom', 'bathtub', 'marble', 'mirror'],
|
||||
colors: ['white', 'marble', 'chrome'],
|
||||
scene: 'luxury bathroom',
|
||||
description: 'A luxury bathroom featuring marble tiles and modern fixtures',
|
||||
confidence: 0.94,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 3.3,
|
||||
},
|
||||
s3Key: 'uploads/user1/batch1/bathroom_pic.jpg',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: completedBatch.id,
|
||||
originalName: 'corrupt_image.jpg',
|
||||
status: ImageStatus.FAILED,
|
||||
fileSize: 0,
|
||||
mimeType: 'image/jpeg',
|
||||
processingError: 'Image file is corrupted and cannot be processed',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Create test images for processing batch
|
||||
const processingBatchImages = await Promise.all([
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: processingBatch.id,
|
||||
originalName: 'garden_view.jpg',
|
||||
proposedName: 'beautiful-garden-with-colorful-flowers.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 4194304,
|
||||
mimeType: 'image/jpeg',
|
||||
dimensions: { width: 3840, height: 2160, aspectRatio: '16:9' },
|
||||
visionTags: {
|
||||
objects: ['garden', 'flowers', 'grass', 'trees'],
|
||||
colors: ['green', 'red', 'yellow', 'purple'],
|
||||
scene: 'beautiful garden',
|
||||
description: 'A beautiful garden with colorful flowers and lush greenery',
|
||||
confidence: 0.97,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 4.2,
|
||||
},
|
||||
s3Key: 'uploads/user2/batch2/garden_view.jpg',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: processingBatch.id,
|
||||
originalName: 'office_space.png',
|
||||
proposedName: 'modern-office-workspace-with-computer.jpg',
|
||||
status: ImageStatus.COMPLETED,
|
||||
fileSize: 2097152,
|
||||
mimeType: 'image/png',
|
||||
dimensions: { width: 2560, height: 1600, aspectRatio: '8:5' },
|
||||
visionTags: {
|
||||
objects: ['desk', 'computer', 'chair', 'monitor'],
|
||||
colors: ['white', 'black', 'blue'],
|
||||
scene: 'modern office',
|
||||
description: 'A modern office workspace with computer and ergonomic furniture',
|
||||
confidence: 0.91,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 3.7,
|
||||
},
|
||||
s3Key: 'uploads/user2/batch2/office_space.png',
|
||||
processedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: processingBatch.id,
|
||||
originalName: 'current_processing.jpg',
|
||||
status: ImageStatus.PROCESSING,
|
||||
fileSize: 1835008,
|
||||
mimeType: 'image/jpeg',
|
||||
s3Key: 'uploads/user2/batch2/current_processing.jpg',
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: processingBatch.id,
|
||||
originalName: 'pending_image_1.jpg',
|
||||
status: ImageStatus.PENDING,
|
||||
fileSize: 2359296,
|
||||
mimeType: 'image/jpeg',
|
||||
s3Key: 'uploads/user2/batch2/pending_image_1.jpg',
|
||||
},
|
||||
}),
|
||||
prisma.image.create({
|
||||
data: {
|
||||
batchId: processingBatch.id,
|
||||
originalName: 'pending_image_2.png',
|
||||
status: ImageStatus.PENDING,
|
||||
fileSize: 1048576,
|
||||
mimeType: 'image/png',
|
||||
s3Key: 'uploads/user2/batch2/pending_image_2.png',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log(`✅ Created ${completedBatchImages.length + processingBatchImages.length} test images`);
|
||||
|
||||
// Create test payments
|
||||
const payments = await Promise.all([
|
||||
prisma.payment.create({
|
||||
data: {
|
||||
userId: users[1].id, // Jane Smith upgrading to PRO
|
||||
stripeSessionId: 'cs_test_stripe_session_123',
|
||||
stripePaymentId: 'pi_test_stripe_payment_123',
|
||||
plan: Plan.PRO,
|
||||
amount: 2999, // $29.99
|
||||
currency: 'usd',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
paidAt: new Date(),
|
||||
metadata: {
|
||||
stripeCustomerId: 'cus_test_customer_123',
|
||||
previousPlan: Plan.BASIC,
|
||||
upgradeReason: 'Need more quota for business use',
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.payment.create({
|
||||
data: {
|
||||
userId: users[2].id, // Bob Wilson upgrading to MAX
|
||||
stripeSessionId: 'cs_test_stripe_session_456',
|
||||
stripePaymentId: 'pi_test_stripe_payment_456',
|
||||
plan: Plan.MAX,
|
||||
amount: 4999, // $49.99
|
||||
currency: 'usd',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
paidAt: new Date(),
|
||||
metadata: {
|
||||
stripeCustomerId: 'cus_test_customer_456',
|
||||
previousPlan: Plan.PRO,
|
||||
upgradeReason: 'Agency needs maximum quota',
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.payment.create({
|
||||
data: {
|
||||
userId: users[0].id, // John Doe failed payment
|
||||
stripeSessionId: 'cs_test_stripe_session_789',
|
||||
plan: Plan.PRO,
|
||||
amount: 2999,
|
||||
currency: 'usd',
|
||||
status: PaymentStatus.FAILED,
|
||||
metadata: {
|
||||
error: 'Insufficient funds',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log(`✅ Created ${payments.length} test payments`);
|
||||
|
||||
// Create test API keys
|
||||
const apiKeys = await Promise.all([
|
||||
prisma.apiKey.create({
|
||||
data: {
|
||||
userId: users[1].id,
|
||||
keyHash: crypto.createHash('sha256').update('test_api_key_pro_user').digest('hex'),
|
||||
name: 'Production API Key',
|
||||
isActive: true,
|
||||
lastUsed: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.apiKey.create({
|
||||
data: {
|
||||
userId: users[2].id,
|
||||
keyHash: crypto.createHash('sha256').update('test_api_key_max_user').digest('hex'),
|
||||
name: 'Development API Key',
|
||||
isActive: true,
|
||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year from now
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log(`✅ Created ${apiKeys.length} test API keys`);
|
||||
|
||||
console.log('🎉 Database seed completed successfully!');
|
||||
|
||||
// Print summary
|
||||
console.log('\n📊 Seed Summary:');
|
||||
console.log(` Users: ${users.length}`);
|
||||
console.log(` Batches: ${batches.length}`);
|
||||
console.log(` Images: ${completedBatchImages.length + processingBatchImages.length}`);
|
||||
console.log(` Payments: ${payments.length}`);
|
||||
console.log(` API Keys: ${apiKeys.length}`);
|
||||
|
||||
console.log('\n👥 Test Users:');
|
||||
users.forEach(user => {
|
||||
console.log(` ${user.email} (${user.plan}) - Quota: ${user.quotaRemaining}`);
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Seed failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
227
packages/api/src/batches/batch.entity.ts
Normal file
227
packages/api/src/batches/batch.entity.ts
Normal file
|
@ -0,0 +1,227 @@
|
|||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsObject,
|
||||
Min,
|
||||
IsDate
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { BatchStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateBatchDto {
|
||||
@ApiProperty({
|
||||
description: 'ID of the user creating the batch',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
||||
})
|
||||
@IsUUID()
|
||||
userId: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Total number of images in this batch',
|
||||
example: 10,
|
||||
minimum: 0
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalImages?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Additional metadata for the batch processing',
|
||||
example: {
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingOptions: { includeColors: true, includeTags: true }
|
||||
}
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class UpdateBatchDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Batch processing status',
|
||||
enum: BatchStatus
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(BatchStatus)
|
||||
status?: BatchStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Total number of images in this batch',
|
||||
minimum: 0
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalImages?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Number of processed images',
|
||||
minimum: 0
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
processedImages?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Number of failed images',
|
||||
minimum: 0
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
failedImages?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Additional metadata for the batch processing'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class BatchResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Unique batch identifier',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
||||
})
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID of the user who owns this batch',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
||||
})
|
||||
@IsUUID()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current batch processing status',
|
||||
enum: BatchStatus
|
||||
})
|
||||
@IsEnum(BatchStatus)
|
||||
status: BatchStatus;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Total number of images in this batch',
|
||||
example: 10
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalImages: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Number of processed images',
|
||||
example: 8
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
processedImages: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Number of failed images',
|
||||
example: 1
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
failedImages: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Additional metadata for the batch processing'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Batch creation timestamp'
|
||||
})
|
||||
@IsDate()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Batch last update timestamp'
|
||||
})
|
||||
@IsDate()
|
||||
updatedAt: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Batch completion timestamp'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
completedAt?: Date;
|
||||
}
|
||||
|
||||
export class BatchStatsDto {
|
||||
@ApiProperty({
|
||||
description: 'Processing progress percentage',
|
||||
example: 80
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
progressPercentage: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Number of pending images',
|
||||
example: 1
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
pendingImages: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Average processing time per image in seconds',
|
||||
example: 5.2
|
||||
})
|
||||
@Type(() => Number)
|
||||
averageProcessingTime: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Estimated time remaining in seconds',
|
||||
example: 30
|
||||
})
|
||||
@Type(() => Number)
|
||||
estimatedTimeRemaining: number;
|
||||
}
|
||||
|
||||
export class BatchSummaryDto {
|
||||
@ApiProperty({
|
||||
description: 'Batch details'
|
||||
})
|
||||
batch: BatchResponseDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Processing statistics'
|
||||
})
|
||||
stats: BatchStatsDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Recent images from this batch (limited to 5)'
|
||||
})
|
||||
recentImages: Array<{
|
||||
id: string;
|
||||
originalName: string;
|
||||
proposedName?: string;
|
||||
status: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Helper function to calculate progress percentage
|
||||
export function calculateProgressPercentage(processedImages: number, totalImages: number): number {
|
||||
if (totalImages === 0) return 0;
|
||||
return Math.round((processedImages / totalImages) * 100);
|
||||
}
|
||||
|
||||
// Helper function to determine if batch is complete
|
||||
export function isBatchComplete(batch: { status: BatchStatus; processedImages: number; failedImages: number; totalImages: number }): boolean {
|
||||
return batch.status === BatchStatus.DONE ||
|
||||
batch.status === BatchStatus.ERROR ||
|
||||
(batch.processedImages + batch.failedImages) >= batch.totalImages;
|
||||
}
|
27
packages/api/src/database/database.module.ts
Normal file
27
packages/api/src/database/database.module.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { UserRepository } from './repositories/user.repository';
|
||||
import { BatchRepository } from './repositories/batch.repository';
|
||||
import { ImageRepository } from './repositories/image.repository';
|
||||
import { PaymentRepository } from './repositories/payment.repository';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
PrismaService,
|
||||
UserRepository,
|
||||
BatchRepository,
|
||||
ImageRepository,
|
||||
PaymentRepository,
|
||||
],
|
||||
exports: [
|
||||
PrismaService,
|
||||
UserRepository,
|
||||
BatchRepository,
|
||||
ImageRepository,
|
||||
PaymentRepository,
|
||||
],
|
||||
})
|
||||
export class DatabaseModule {}
|
138
packages/api/src/database/prisma.service.ts
Normal file
138
packages/api/src/database/prisma.service.ts
Normal file
|
@ -0,0 +1,138 @@
|
|||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
super({
|
||||
datasources: {
|
||||
db: {
|
||||
url: configService.get<string>('DATABASE_URL'),
|
||||
},
|
||||
},
|
||||
log: [
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'query',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'error',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'info',
|
||||
},
|
||||
{
|
||||
emit: 'event',
|
||||
level: 'warn',
|
||||
},
|
||||
],
|
||||
errorFormat: 'colorless',
|
||||
});
|
||||
|
||||
// Log database queries in development
|
||||
if (configService.get('NODE_ENV') === 'development') {
|
||||
this.$on('query', (e) => {
|
||||
this.logger.debug(`Query: ${e.query}`);
|
||||
this.logger.debug(`Params: ${e.params}`);
|
||||
this.logger.debug(`Duration: ${e.duration}ms`);
|
||||
});
|
||||
}
|
||||
|
||||
// Log database errors
|
||||
this.$on('error', (e) => {
|
||||
this.logger.error('Database error:', e);
|
||||
});
|
||||
|
||||
// Log database info
|
||||
this.$on('info', (e) => {
|
||||
this.logger.log(`Database info: ${e.message}`);
|
||||
});
|
||||
|
||||
// Log database warnings
|
||||
this.$on('warn', (e) => {
|
||||
this.logger.warn(`Database warning: ${e.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.$connect();
|
||||
this.logger.log('Successfully connected to database');
|
||||
|
||||
// Test the connection
|
||||
await this.$queryRaw`SELECT 1`;
|
||||
this.logger.log('Database connection test passed');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to connect to database:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
await this.$disconnect();
|
||||
this.logger.log('Disconnected from database');
|
||||
} catch (error) {
|
||||
this.logger.error('Error during database disconnection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean shutdown method for graceful application termination
|
||||
*/
|
||||
async enableShutdownHooks() {
|
||||
process.on('beforeExit', async () => {
|
||||
await this.$disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check method to verify database connectivity
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.$queryRaw`SELECT 1`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('Database health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database statistics
|
||||
*/
|
||||
async getDatabaseStats() {
|
||||
try {
|
||||
const [userCount, batchCount, imageCount, paymentCount] = await Promise.all([
|
||||
this.user.count(),
|
||||
this.batch.count(),
|
||||
this.image.count(),
|
||||
this.payment.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
users: userCount,
|
||||
batches: batchCount,
|
||||
images: imageCount,
|
||||
payments: paymentCount,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get database stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaction helper method
|
||||
*/
|
||||
async transaction<T>(fn: (prisma: PrismaClient) => Promise<T>): Promise<T> {
|
||||
return this.$transaction(fn);
|
||||
}
|
||||
}
|
349
packages/api/src/database/repositories/batch.repository.ts
Normal file
349
packages/api/src/database/repositories/batch.repository.ts
Normal file
|
@ -0,0 +1,349 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Batch, BatchStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma.service';
|
||||
import { CreateBatchDto, UpdateBatchDto } from '../../batches/batch.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BatchRepository {
|
||||
private readonly logger = new Logger(BatchRepository.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Create a new batch
|
||||
*/
|
||||
async create(data: CreateBatchDto): Promise<Batch> {
|
||||
try {
|
||||
return await this.prisma.batch.create({
|
||||
data: {
|
||||
...data,
|
||||
status: BatchStatus.PROCESSING,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create batch:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find batch by ID
|
||||
*/
|
||||
async findById(id: string): Promise<Batch | null> {
|
||||
try {
|
||||
return await this.prisma.batch.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find batch by ID ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update batch
|
||||
*/
|
||||
async update(id: string, data: UpdateBatchDto): Promise<Batch> {
|
||||
try {
|
||||
const updateData: any = { ...data };
|
||||
|
||||
// Set completedAt if status is changing to DONE or ERROR
|
||||
if (data.status && (data.status === BatchStatus.DONE || data.status === BatchStatus.ERROR)) {
|
||||
updateData.completedAt = new Date();
|
||||
}
|
||||
|
||||
return await this.prisma.batch.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update batch ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete batch
|
||||
*/
|
||||
async delete(id: string): Promise<Batch> {
|
||||
try {
|
||||
return await this.prisma.batch.delete({
|
||||
where: { id },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to delete batch ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find batches with pagination
|
||||
*/
|
||||
async findMany(params: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
where?: Prisma.BatchWhereInput;
|
||||
orderBy?: Prisma.BatchOrderByWithRelationInput;
|
||||
}): Promise<Batch[]> {
|
||||
try {
|
||||
return await this.prisma.batch.findMany({
|
||||
skip: params.skip,
|
||||
take: params.take,
|
||||
where: params.where,
|
||||
orderBy: params.orderBy,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to find batches:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find batches by user ID
|
||||
*/
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
params?: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
status?: BatchStatus;
|
||||
orderBy?: Prisma.BatchOrderByWithRelationInput;
|
||||
}
|
||||
): Promise<Batch[]> {
|
||||
try {
|
||||
return await this.prisma.batch.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(params?.status && { status: params.status }),
|
||||
},
|
||||
skip: params?.skip,
|
||||
take: params?.take,
|
||||
orderBy: params?.orderBy || { createdAt: 'desc' },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find batches for user ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count batches
|
||||
*/
|
||||
async count(where?: Prisma.BatchWhereInput): Promise<number> {
|
||||
try {
|
||||
return await this.prisma.batch.count({ where });
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to count batches:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find batch with images
|
||||
*/
|
||||
async findByIdWithImages(id: string): Promise<Batch & {
|
||||
images: any[];
|
||||
user: any;
|
||||
_count: { images: number };
|
||||
} | null> {
|
||||
try {
|
||||
return await this.prisma.batch.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
plan: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { images: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find batch with images ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update batch progress
|
||||
*/
|
||||
async updateProgress(id: string, processedImages: number, failedImages: number): Promise<Batch> {
|
||||
try {
|
||||
const batch = await this.findById(id);
|
||||
if (!batch) {
|
||||
throw new Error(`Batch ${id} not found`);
|
||||
}
|
||||
|
||||
// Determine if batch is complete
|
||||
const totalProcessed = processedImages + failedImages;
|
||||
const isComplete = totalProcessed >= batch.totalImages;
|
||||
|
||||
const updateData: any = {
|
||||
processedImages,
|
||||
failedImages,
|
||||
};
|
||||
|
||||
if (isComplete) {
|
||||
updateData.status = failedImages === batch.totalImages ? BatchStatus.ERROR : BatchStatus.DONE;
|
||||
updateData.completedAt = new Date();
|
||||
}
|
||||
|
||||
return await this.prisma.batch.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update batch progress ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment processed images count
|
||||
*/
|
||||
async incrementProcessedImages(id: string): Promise<Batch> {
|
||||
try {
|
||||
return await this.prisma.batch.update({
|
||||
where: { id },
|
||||
data: {
|
||||
processedImages: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to increment processed images for batch ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment failed images count
|
||||
*/
|
||||
async incrementFailedImages(id: string): Promise<Batch> {
|
||||
try {
|
||||
return await this.prisma.batch.update({
|
||||
where: { id },
|
||||
data: {
|
||||
failedImages: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to increment failed images for batch ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find processing batches (for cleanup/monitoring)
|
||||
*/
|
||||
async findProcessingBatches(olderThanMinutes?: number): Promise<Batch[]> {
|
||||
try {
|
||||
const where: Prisma.BatchWhereInput = {
|
||||
status: BatchStatus.PROCESSING,
|
||||
};
|
||||
|
||||
if (olderThanMinutes) {
|
||||
const cutoffTime = new Date();
|
||||
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
|
||||
where.createdAt = { lte: cutoffTime };
|
||||
}
|
||||
|
||||
return await this.prisma.batch.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to find processing batches:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch statistics
|
||||
*/
|
||||
async getBatchStats(batchId: string): Promise<{
|
||||
totalImages: number;
|
||||
processedImages: number;
|
||||
failedImages: number;
|
||||
pendingImages: number;
|
||||
progressPercentage: number;
|
||||
averageProcessingTime?: number;
|
||||
}> {
|
||||
try {
|
||||
const batch = await this.findByIdWithImages(batchId);
|
||||
if (!batch) {
|
||||
throw new Error(`Batch ${batchId} not found`);
|
||||
}
|
||||
|
||||
const pendingImages = batch.totalImages - batch.processedImages - batch.failedImages;
|
||||
const progressPercentage = Math.round(
|
||||
((batch.processedImages + batch.failedImages) / batch.totalImages) * 100
|
||||
);
|
||||
|
||||
// Calculate average processing time from completed images
|
||||
const completedImages = batch.images.filter(img => img.processedAt);
|
||||
let averageProcessingTime: number | undefined;
|
||||
|
||||
if (completedImages.length > 0) {
|
||||
const totalProcessingTime = completedImages.reduce((sum, img) => {
|
||||
const processingTime = img.processedAt.getTime() - img.createdAt.getTime();
|
||||
return sum + processingTime;
|
||||
}, 0);
|
||||
averageProcessingTime = totalProcessingTime / completedImages.length / 1000; // Convert to seconds
|
||||
}
|
||||
|
||||
return {
|
||||
totalImages: batch.totalImages,
|
||||
processedImages: batch.processedImages,
|
||||
failedImages: batch.failedImages,
|
||||
pendingImages,
|
||||
progressPercentage,
|
||||
averageProcessingTime,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get batch stats for ${batchId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user batch statistics
|
||||
*/
|
||||
async getUserBatchStats(userId: string): Promise<{
|
||||
totalBatches: number;
|
||||
completedBatches: number;
|
||||
processingBatches: number;
|
||||
errorBatches: number;
|
||||
totalImages: number;
|
||||
}> {
|
||||
try {
|
||||
const [totalBatches, completedBatches, processingBatches, errorBatches, imageStats] = await Promise.all([
|
||||
this.count({ userId }),
|
||||
this.count({ userId, status: BatchStatus.DONE }),
|
||||
this.count({ userId, status: BatchStatus.PROCESSING }),
|
||||
this.count({ userId, status: BatchStatus.ERROR }),
|
||||
this.prisma.batch.aggregate({
|
||||
where: { userId },
|
||||
_sum: { totalImages: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalBatches,
|
||||
completedBatches,
|
||||
processingBatches,
|
||||
errorBatches,
|
||||
totalImages: imageStats._sum.totalImages || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get user batch stats for ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
457
packages/api/src/database/repositories/image.repository.ts
Normal file
457
packages/api/src/database/repositories/image.repository.ts
Normal file
|
@ -0,0 +1,457 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Image, ImageStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma.service';
|
||||
import { CreateImageDto, UpdateImageDto } from '../../images/image.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ImageRepository {
|
||||
private readonly logger = new Logger(ImageRepository.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Create a new image
|
||||
*/
|
||||
async create(data: CreateImageDto): Promise<Image> {
|
||||
try {
|
||||
return await this.prisma.image.create({
|
||||
data: {
|
||||
...data,
|
||||
status: ImageStatus.PENDING,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create image:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple images in batch
|
||||
*/
|
||||
async createMany(images: CreateImageDto[]): Promise<{ count: number }> {
|
||||
try {
|
||||
const data = images.map(img => ({
|
||||
...img,
|
||||
status: ImageStatus.PENDING,
|
||||
}));
|
||||
|
||||
return await this.prisma.image.createMany({
|
||||
data,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create multiple images:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find image by ID
|
||||
*/
|
||||
async findById(id: string): Promise<Image | null> {
|
||||
try {
|
||||
return await this.prisma.image.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find image by ID ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update image
|
||||
*/
|
||||
async update(id: string, data: UpdateImageDto): Promise<Image> {
|
||||
try {
|
||||
const updateData: any = { ...data };
|
||||
|
||||
// Set processedAt if status is changing to COMPLETED or FAILED
|
||||
if (data.status && (data.status === ImageStatus.COMPLETED || data.status === ImageStatus.FAILED)) {
|
||||
updateData.processedAt = new Date();
|
||||
}
|
||||
|
||||
return await this.prisma.image.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update image ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete image
|
||||
*/
|
||||
async delete(id: string): Promise<Image> {
|
||||
try {
|
||||
return await this.prisma.image.delete({
|
||||
where: { id },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to delete image ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find images with pagination
|
||||
*/
|
||||
async findMany(params: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
where?: Prisma.ImageWhereInput;
|
||||
orderBy?: Prisma.ImageOrderByWithRelationInput;
|
||||
}): Promise<Image[]> {
|
||||
try {
|
||||
return await this.prisma.image.findMany({
|
||||
skip: params.skip,
|
||||
take: params.take,
|
||||
where: params.where,
|
||||
orderBy: params.orderBy,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to find images:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find images by batch ID
|
||||
*/
|
||||
async findByBatchId(
|
||||
batchId: string,
|
||||
params?: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
status?: ImageStatus;
|
||||
orderBy?: Prisma.ImageOrderByWithRelationInput;
|
||||
}
|
||||
): Promise<Image[]> {
|
||||
try {
|
||||
return await this.prisma.image.findMany({
|
||||
where: {
|
||||
batchId,
|
||||
...(params?.status && { status: params.status }),
|
||||
},
|
||||
skip: params?.skip,
|
||||
take: params?.take,
|
||||
orderBy: params?.orderBy || { createdAt: 'asc' },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find images for batch ${batchId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count images
|
||||
*/
|
||||
async count(where?: Prisma.ImageWhereInput): Promise<number> {
|
||||
try {
|
||||
return await this.prisma.image.count({ where });
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to count images:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find image with batch info
|
||||
*/
|
||||
async findByIdWithBatch(id: string): Promise<Image & {
|
||||
batch: any;
|
||||
} | null> {
|
||||
try {
|
||||
return await this.prisma.image.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
batch: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
plan: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find image with batch ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update image status
|
||||
*/
|
||||
async updateStatus(id: string, status: ImageStatus, error?: string): Promise<Image> {
|
||||
try {
|
||||
const updateData: any = {
|
||||
status,
|
||||
...(error && { processingError: error }),
|
||||
};
|
||||
|
||||
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
|
||||
updateData.processedAt = new Date();
|
||||
}
|
||||
|
||||
return await this.prisma.image.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update image status ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update image statuses
|
||||
*/
|
||||
async bulkUpdateStatus(imageIds: string[], status: ImageStatus): Promise<{ count: number }> {
|
||||
try {
|
||||
const updateData: any = { status };
|
||||
|
||||
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
|
||||
updateData.processedAt = new Date();
|
||||
}
|
||||
|
||||
return await this.prisma.image.updateMany({
|
||||
where: {
|
||||
id: { in: imageIds },
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to bulk update image statuses:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply proposed names as final names
|
||||
*/
|
||||
async applyProposedNames(imageIds: string[]): Promise<{ count: number }> {
|
||||
try {
|
||||
// First, get all images with their proposed names
|
||||
const images = await this.prisma.image.findMany({
|
||||
where: {
|
||||
id: { in: imageIds },
|
||||
proposedName: { not: null },
|
||||
},
|
||||
select: { id: true, proposedName: true },
|
||||
});
|
||||
|
||||
// Use transaction to update each image with its proposed name as final name
|
||||
const results = await this.prisma.$transaction(
|
||||
images.map(image =>
|
||||
this.prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { finalName: image.proposedName },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return { count: results.length };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to apply proposed names:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find pending images for processing
|
||||
*/
|
||||
async findPendingImages(limit?: number): Promise<Image[]> {
|
||||
try {
|
||||
return await this.prisma.image.findMany({
|
||||
where: {
|
||||
status: ImageStatus.PENDING,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: limit,
|
||||
include: {
|
||||
batch: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
plan: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to find pending images:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find processing images (for cleanup/monitoring)
|
||||
*/
|
||||
async findProcessingImages(olderThanMinutes?: number): Promise<Image[]> {
|
||||
try {
|
||||
const where: Prisma.ImageWhereInput = {
|
||||
status: ImageStatus.PROCESSING,
|
||||
};
|
||||
|
||||
if (olderThanMinutes) {
|
||||
const cutoffTime = new Date();
|
||||
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
|
||||
where.updatedAt = { lte: cutoffTime };
|
||||
}
|
||||
|
||||
return await this.prisma.image.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to find processing images:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image processing statistics for a batch
|
||||
*/
|
||||
async getBatchImageStats(batchId: string): Promise<{
|
||||
total: number;
|
||||
pending: number;
|
||||
processing: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
}> {
|
||||
try {
|
||||
const [total, pending, processing, completed, failed] = await Promise.all([
|
||||
this.count({ batchId }),
|
||||
this.count({ batchId, status: ImageStatus.PENDING }),
|
||||
this.count({ batchId, status: ImageStatus.PROCESSING }),
|
||||
this.count({ batchId, status: ImageStatus.COMPLETED }),
|
||||
this.count({ batchId, status: ImageStatus.FAILED }),
|
||||
]);
|
||||
|
||||
return {
|
||||
total,
|
||||
pending,
|
||||
processing,
|
||||
completed,
|
||||
failed,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get batch image stats for ${batchId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user image processing statistics
|
||||
*/
|
||||
async getUserImageStats(userId: string): Promise<{
|
||||
totalImages: number;
|
||||
completedImages: number;
|
||||
failedImages: number;
|
||||
processingImages: number;
|
||||
pendingImages: number;
|
||||
}> {
|
||||
try {
|
||||
const [
|
||||
totalImages,
|
||||
completedImages,
|
||||
failedImages,
|
||||
processingImages,
|
||||
pendingImages,
|
||||
] = await Promise.all([
|
||||
this.prisma.image.count({
|
||||
where: {
|
||||
batch: { userId },
|
||||
},
|
||||
}),
|
||||
this.prisma.image.count({
|
||||
where: {
|
||||
batch: { userId },
|
||||
status: ImageStatus.COMPLETED,
|
||||
},
|
||||
}),
|
||||
this.prisma.image.count({
|
||||
where: {
|
||||
batch: { userId },
|
||||
status: ImageStatus.FAILED,
|
||||
},
|
||||
}),
|
||||
this.prisma.image.count({
|
||||
where: {
|
||||
batch: { userId },
|
||||
status: ImageStatus.PROCESSING,
|
||||
},
|
||||
}),
|
||||
this.prisma.image.count({
|
||||
where: {
|
||||
batch: { userId },
|
||||
status: ImageStatus.PENDING,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalImages,
|
||||
completedImages,
|
||||
failedImages,
|
||||
processingImages,
|
||||
pendingImages,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get user image stats for ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search images by original name
|
||||
*/
|
||||
async searchByOriginalName(
|
||||
searchTerm: string,
|
||||
userId?: string,
|
||||
params?: { skip?: number; take?: number }
|
||||
): Promise<Image[]> {
|
||||
try {
|
||||
const where: Prisma.ImageWhereInput = {
|
||||
originalName: {
|
||||
contains: searchTerm,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
...(userId && {
|
||||
batch: { userId },
|
||||
}),
|
||||
};
|
||||
|
||||
return await this.prisma.image.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: params?.skip,
|
||||
take: params?.take,
|
||||
include: {
|
||||
batch: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to search images by original name:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
437
packages/api/src/database/repositories/payment.repository.ts
Normal file
437
packages/api/src/database/repositories/payment.repository.ts
Normal file
|
@ -0,0 +1,437 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Payment, PaymentStatus, Plan, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma.service';
|
||||
import { CreatePaymentDto, UpdatePaymentDto } from '../../payments/payment.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentRepository {
|
||||
private readonly logger = new Logger(PaymentRepository.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Create a new payment
|
||||
*/
|
||||
async create(data: CreatePaymentDto): Promise<Payment> {
|
||||
try {
|
||||
return await this.prisma.payment.create({
|
||||
data: {
|
||||
...data,
|
||||
status: PaymentStatus.PENDING,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create payment:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payment by ID
|
||||
*/
|
||||
async findById(id: string): Promise<Payment | null> {
|
||||
try {
|
||||
return await this.prisma.payment.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find payment by ID ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payment by Stripe Session ID
|
||||
*/
|
||||
async findByStripeSessionId(stripeSessionId: string): Promise<Payment | null> {
|
||||
try {
|
||||
return await this.prisma.payment.findUnique({
|
||||
where: { stripeSessionId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find payment by Stripe Session ID ${stripeSessionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payment by Stripe Payment ID
|
||||
*/
|
||||
async findByStripePaymentId(stripePaymentId: string): Promise<Payment | null> {
|
||||
try {
|
||||
return await this.prisma.payment.findUnique({
|
||||
where: { stripePaymentId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find payment by Stripe Payment ID ${stripePaymentId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment
|
||||
*/
|
||||
async update(id: string, data: UpdatePaymentDto): Promise<Payment> {
|
||||
try {
|
||||
const updateData: any = { ...data };
|
||||
|
||||
// Set paidAt if status is changing to COMPLETED
|
||||
if (data.status === PaymentStatus.COMPLETED) {
|
||||
updateData.paidAt = new Date();
|
||||
}
|
||||
|
||||
return await this.prisma.payment.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update payment ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete payment
|
||||
*/
|
||||
async delete(id: string): Promise<Payment> {
|
||||
try {
|
||||
return await this.prisma.payment.delete({
|
||||
where: { id },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to delete payment ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payments with pagination
|
||||
*/
|
||||
async findMany(params: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
where?: Prisma.PaymentWhereInput;
|
||||
orderBy?: Prisma.PaymentOrderByWithRelationInput;
|
||||
}): Promise<Payment[]> {
|
||||
try {
|
||||
return await this.prisma.payment.findMany({
|
||||
skip: params.skip,
|
||||
take: params.take,
|
||||
where: params.where,
|
||||
orderBy: params.orderBy,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to find payments:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payments by user ID
|
||||
*/
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
params?: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
status?: PaymentStatus;
|
||||
orderBy?: Prisma.PaymentOrderByWithRelationInput;
|
||||
}
|
||||
): Promise<Payment[]> {
|
||||
try {
|
||||
return await this.prisma.payment.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(params?.status && { status: params.status }),
|
||||
},
|
||||
skip: params?.skip,
|
||||
take: params?.take,
|
||||
orderBy: params?.orderBy || { createdAt: 'desc' },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find payments for user ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count payments
|
||||
*/
|
||||
async count(where?: Prisma.PaymentWhereInput): Promise<number> {
|
||||
try {
|
||||
return await this.prisma.payment.count({ where });
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to count payments:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payment with user info
|
||||
*/
|
||||
async findByIdWithUser(id: string): Promise<Payment & {
|
||||
user: any;
|
||||
} | null> {
|
||||
try {
|
||||
return await this.prisma.payment.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
plan: true,
|
||||
quotaRemaining: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find payment with user ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment status
|
||||
*/
|
||||
async updateStatus(id: string, status: PaymentStatus, stripePaymentId?: string): Promise<Payment> {
|
||||
try {
|
||||
const updateData: any = { status };
|
||||
|
||||
if (stripePaymentId) {
|
||||
updateData.stripePaymentId = stripePaymentId;
|
||||
}
|
||||
|
||||
if (status === PaymentStatus.COMPLETED) {
|
||||
updateData.paidAt = new Date();
|
||||
}
|
||||
|
||||
return await this.prisma.payment.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update payment status ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find successful payments by user
|
||||
*/
|
||||
async findSuccessfulPaymentsByUserId(userId: string): Promise<Payment[]> {
|
||||
try {
|
||||
return await this.prisma.payment.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: PaymentStatus.COMPLETED,
|
||||
},
|
||||
orderBy: { paidAt: 'desc' },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to find successful payments for user ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user payment statistics
|
||||
*/
|
||||
async getUserPaymentStats(userId: string): Promise<{
|
||||
totalPayments: number;
|
||||
successfulPayments: number;
|
||||
failedPayments: number;
|
||||
totalAmountSpent: number;
|
||||
lastPaymentDate?: Date;
|
||||
averagePaymentAmount: number;
|
||||
}> {
|
||||
try {
|
||||
const [
|
||||
totalPayments,
|
||||
successfulPayments,
|
||||
failedPayments,
|
||||
amountStats,
|
||||
lastSuccessfulPayment,
|
||||
] = await Promise.all([
|
||||
this.count({ userId }),
|
||||
this.count({ userId, status: PaymentStatus.COMPLETED }),
|
||||
this.count({
|
||||
userId,
|
||||
status: { in: [PaymentStatus.FAILED, PaymentStatus.CANCELLED] }
|
||||
}),
|
||||
this.prisma.payment.aggregate({
|
||||
where: {
|
||||
userId,
|
||||
status: PaymentStatus.COMPLETED
|
||||
},
|
||||
_sum: { amount: true },
|
||||
_avg: { amount: true },
|
||||
}),
|
||||
this.prisma.payment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
status: PaymentStatus.COMPLETED
|
||||
},
|
||||
orderBy: { paidAt: 'desc' },
|
||||
select: { paidAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalPayments,
|
||||
successfulPayments,
|
||||
failedPayments,
|
||||
totalAmountSpent: amountStats._sum.amount || 0,
|
||||
lastPaymentDate: lastSuccessfulPayment?.paidAt || undefined,
|
||||
averagePaymentAmount: Math.round(amountStats._avg.amount || 0),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get user payment stats for ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find pending payments (for cleanup/monitoring)
|
||||
*/
|
||||
async findPendingPayments(olderThanMinutes?: number): Promise<Payment[]> {
|
||||
try {
|
||||
const where: Prisma.PaymentWhereInput = {
|
||||
status: PaymentStatus.PENDING,
|
||||
};
|
||||
|
||||
if (olderThanMinutes) {
|
||||
const cutoffTime = new Date();
|
||||
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
|
||||
where.createdAt = { lte: cutoffTime };
|
||||
}
|
||||
|
||||
return await this.prisma.payment.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to find pending payments:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get revenue statistics
|
||||
*/
|
||||
async getRevenueStats(params?: {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
plan?: Plan;
|
||||
}): Promise<{
|
||||
totalRevenue: number;
|
||||
totalPayments: number;
|
||||
averagePaymentAmount: number;
|
||||
revenueByPlan: Record<Plan, number>;
|
||||
paymentsCount: Record<PaymentStatus, number>;
|
||||
}> {
|
||||
try {
|
||||
const where: Prisma.PaymentWhereInput = {
|
||||
status: PaymentStatus.COMPLETED,
|
||||
...(params?.startDate && { createdAt: { gte: params.startDate } }),
|
||||
...(params?.endDate && { createdAt: { lte: params.endDate } }),
|
||||
...(params?.plan && { plan: params.plan }),
|
||||
};
|
||||
|
||||
const [revenueStats, revenueByPlan, paymentStatusCounts] = await Promise.all([
|
||||
this.prisma.payment.aggregate({
|
||||
where,
|
||||
_sum: { amount: true },
|
||||
_count: true,
|
||||
_avg: { amount: true },
|
||||
}),
|
||||
this.prisma.payment.groupBy({
|
||||
by: ['plan'],
|
||||
where,
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
this.prisma.payment.groupBy({
|
||||
by: ['status'],
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const revenueByPlanMap = Object.values(Plan).reduce((acc, plan) => {
|
||||
acc[plan] = 0;
|
||||
return acc;
|
||||
}, {} as Record<Plan, number>);
|
||||
|
||||
revenueByPlan.forEach(item => {
|
||||
revenueByPlanMap[item.plan] = item._sum.amount || 0;
|
||||
});
|
||||
|
||||
const paymentsCountMap = Object.values(PaymentStatus).reduce((acc, status) => {
|
||||
acc[status] = 0;
|
||||
return acc;
|
||||
}, {} as Record<PaymentStatus, number>);
|
||||
|
||||
paymentStatusCounts.forEach(item => {
|
||||
paymentsCountMap[item.status] = item._count;
|
||||
});
|
||||
|
||||
return {
|
||||
totalRevenue: revenueStats._sum.amount || 0,
|
||||
totalPayments: revenueStats._count,
|
||||
averagePaymentAmount: Math.round(revenueStats._avg.amount || 0),
|
||||
revenueByPlan: revenueByPlanMap,
|
||||
paymentsCount: paymentsCountMap,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get revenue stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payments by date range
|
||||
*/
|
||||
async findPaymentsByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
params?: {
|
||||
userId?: string;
|
||||
status?: PaymentStatus;
|
||||
plan?: Plan;
|
||||
}
|
||||
): Promise<Payment[]> {
|
||||
try {
|
||||
return await this.prisma.payment.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
...(params?.userId && { userId: params.userId }),
|
||||
...(params?.status && { status: params.status }),
|
||||
...(params?.plan && { plan: params.plan }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to find payments by date range:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
309
packages/api/src/database/repositories/user.repository.ts
Normal file
309
packages/api/src/database/repositories/user.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
349
packages/api/src/images/image.entity.ts
Normal file
349
packages/api/src/images/image.entity.ts
Normal file
|
@ -0,0 +1,349 @@
|
|||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsObject,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Min,
|
||||
IsDate
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ImageStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export interface VisionTagsInterface {
|
||||
objects?: string[];
|
||||
colors?: string[];
|
||||
scene?: string;
|
||||
description?: string;
|
||||
confidence?: number;
|
||||
aiModel?: string;
|
||||
processingTime?: number;
|
||||
}
|
||||
|
||||
export interface ImageDimensionsInterface {
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio?: string;
|
||||
}
|
||||
|
||||
export class CreateImageDto {
|
||||
@ApiProperty({
|
||||
description: 'ID of the batch this image belongs to',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
||||
})
|
||||
@IsUUID()
|
||||
batchId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Original filename of the image',
|
||||
example: 'IMG_20240101_123456.jpg'
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
originalName: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'File size in bytes',
|
||||
example: 2048576
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
fileSize?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'MIME type of the image',
|
||||
example: 'image/jpeg'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mimeType?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Image dimensions',
|
||||
example: { width: 1920, height: 1080, aspectRatio: '16:9' }
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
dimensions?: ImageDimensionsInterface;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'S3 object key for storage',
|
||||
example: 'uploads/user123/batch456/original/image.jpg'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
s3Key?: string;
|
||||
}
|
||||
|
||||
export class UpdateImageDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'AI-generated proposed filename',
|
||||
example: 'modern-kitchen-with-stainless-steel-appliances.jpg'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
proposedName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'User-approved final filename',
|
||||
example: 'kitchen-renovation-final.jpg'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
finalName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'AI vision analysis results',
|
||||
example: {
|
||||
objects: ['kitchen', 'refrigerator', 'countertop'],
|
||||
colors: ['white', 'stainless steel', 'black'],
|
||||
scene: 'modern kitchen interior',
|
||||
description: 'A modern kitchen with stainless steel appliances',
|
||||
confidence: 0.95,
|
||||
aiModel: 'gpt-4-vision',
|
||||
processingTime: 2.5
|
||||
}
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
visionTags?: VisionTagsInterface;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Image processing status',
|
||||
enum: ImageStatus
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ImageStatus)
|
||||
status?: ImageStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Error message if processing failed',
|
||||
example: 'Image format not supported'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
processingError?: string;
|
||||
}
|
||||
|
||||
export class ImageResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Unique image identifier',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
||||
})
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID of the batch this image belongs to',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
||||
})
|
||||
@IsUUID()
|
||||
batchId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Original filename of the image',
|
||||
example: 'IMG_20240101_123456.jpg'
|
||||
})
|
||||
@IsString()
|
||||
originalName: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'AI-generated proposed filename',
|
||||
example: 'modern-kitchen-with-stainless-steel-appliances.jpg'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
proposedName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'User-approved final filename',
|
||||
example: 'kitchen-renovation-final.jpg'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
finalName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'AI vision analysis results'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
visionTags?: VisionTagsInterface;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current image processing status',
|
||||
enum: ImageStatus
|
||||
})
|
||||
@IsEnum(ImageStatus)
|
||||
status: ImageStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'File size in bytes',
|
||||
example: 2048576
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
fileSize?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Image dimensions'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
dimensions?: ImageDimensionsInterface;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'MIME type of the image',
|
||||
example: 'image/jpeg'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mimeType?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'S3 object key for storage',
|
||||
example: 'uploads/user123/batch456/original/image.jpg'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
s3Key?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Error message if processing failed'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
processingError?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Image creation timestamp'
|
||||
})
|
||||
@IsDate()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Image last update timestamp'
|
||||
})
|
||||
@IsDate()
|
||||
updatedAt: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Image processing completion timestamp'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
processedAt?: Date;
|
||||
}
|
||||
|
||||
export class ImageProcessingResultDto {
|
||||
@ApiProperty({
|
||||
description: 'Image details'
|
||||
})
|
||||
image: ImageResponseDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Processing success status'
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Processing time in seconds'
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
processingTime?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Error details if processing failed'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class BulkImageUpdateDto {
|
||||
@ApiProperty({
|
||||
description: 'Array of image IDs to update',
|
||||
example: ['550e8400-e29b-41d4-a716-446655440000', '660f9511-f39c-52e5-b827-557766551111']
|
||||
})
|
||||
@IsUUID(undefined, { each: true })
|
||||
imageIds: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Status to set for all images',
|
||||
enum: ImageStatus
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ImageStatus)
|
||||
status?: ImageStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Apply proposed names as final names for all images'
|
||||
})
|
||||
@IsOptional()
|
||||
applyProposedNames?: boolean;
|
||||
}
|
||||
|
||||
// Helper function to generate SEO-friendly filename
|
||||
export function generateSeoFriendlyFilename(
|
||||
visionTags: VisionTagsInterface,
|
||||
originalName: string
|
||||
): string {
|
||||
if (!visionTags.objects && !visionTags.description) {
|
||||
return originalName;
|
||||
}
|
||||
|
||||
let filename = '';
|
||||
|
||||
// Use description if available, otherwise use objects
|
||||
if (visionTags.description) {
|
||||
filename = visionTags.description
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
||||
.substring(0, 100); // Limit length
|
||||
} else if (visionTags.objects && visionTags.objects.length > 0) {
|
||||
filename = visionTags.objects
|
||||
.slice(0, 3) // Take first 3 objects
|
||||
.join('-')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.substring(0, 100);
|
||||
}
|
||||
|
||||
// Get file extension from original name
|
||||
const extension = originalName.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
|
||||
return filename ? `${filename}.${extension}` : originalName;
|
||||
}
|
||||
|
||||
// Helper function to validate image file type
|
||||
export function isValidImageType(mimeType: string): boolean {
|
||||
const validTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/tiff'
|
||||
];
|
||||
return validTypes.includes(mimeType.toLowerCase());
|
||||
}
|
||||
|
||||
// Helper function to calculate aspect ratio
|
||||
export function calculateAspectRatio(width: number, height: number): string {
|
||||
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
|
||||
const divisor = gcd(width, height);
|
||||
return `${width / divisor}:${height / divisor}`;
|
||||
}
|
344
packages/api/src/payments/payment.entity.ts
Normal file
344
packages/api/src/payments/payment.entity.ts
Normal file
|
@ -0,0 +1,344 @@
|
|||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsObject,
|
||||
Min,
|
||||
IsDate,
|
||||
Length
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Plan, PaymentStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export interface PaymentMetadataInterface {
|
||||
stripeCustomerId?: string;
|
||||
subscriptionId?: string;
|
||||
priceId?: string;
|
||||
previousPlan?: Plan;
|
||||
upgradeReason?: string;
|
||||
discountCode?: string;
|
||||
discountAmount?: number;
|
||||
tax?: {
|
||||
amount: number;
|
||||
rate: number;
|
||||
country: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class CreatePaymentDto {
|
||||
@ApiProperty({
|
||||
description: 'ID of the user making the payment',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
||||
})
|
||||
@IsUUID()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Plan being purchased',
|
||||
enum: Plan,
|
||||
example: Plan.PRO
|
||||
})
|
||||
@IsEnum(Plan)
|
||||
plan: Plan;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Payment amount in cents',
|
||||
example: 2999,
|
||||
minimum: 0
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
amount: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Payment currency',
|
||||
example: 'usd',
|
||||
default: 'usd'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(3, 3)
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Stripe Checkout Session ID',
|
||||
example: 'cs_test_123456789'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stripeSessionId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Additional payment metadata'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: PaymentMetadataInterface;
|
||||
}
|
||||
|
||||
export class UpdatePaymentDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Payment status',
|
||||
enum: PaymentStatus
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(PaymentStatus)
|
||||
status?: PaymentStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Stripe Payment Intent ID',
|
||||
example: 'pi_123456789'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stripePaymentId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Additional payment metadata'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: PaymentMetadataInterface;
|
||||
}
|
||||
|
||||
export class PaymentResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Unique payment identifier',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
||||
})
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID of the user who made the payment',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
||||
})
|
||||
@IsUUID()
|
||||
userId: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Stripe Checkout Session ID',
|
||||
example: 'cs_test_123456789'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stripeSessionId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Stripe Payment Intent ID',
|
||||
example: 'pi_123456789'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stripePaymentId?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Plan that was purchased',
|
||||
enum: Plan
|
||||
})
|
||||
@IsEnum(Plan)
|
||||
plan: Plan;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Payment amount in cents',
|
||||
example: 2999
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Payment currency',
|
||||
example: 'usd'
|
||||
})
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current payment status',
|
||||
enum: PaymentStatus
|
||||
})
|
||||
@IsEnum(PaymentStatus)
|
||||
status: PaymentStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Additional payment metadata'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: PaymentMetadataInterface;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Payment creation timestamp'
|
||||
})
|
||||
@IsDate()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Payment last update timestamp'
|
||||
})
|
||||
@IsDate()
|
||||
updatedAt: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Payment completion timestamp'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
paidAt?: Date;
|
||||
}
|
||||
|
||||
export class StripeCheckoutSessionDto {
|
||||
@ApiProperty({
|
||||
description: 'Plan to purchase',
|
||||
enum: Plan
|
||||
})
|
||||
@IsEnum(Plan)
|
||||
plan: Plan;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Success URL after payment',
|
||||
example: 'https://app.example.com/success'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
successUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Cancel URL if payment is cancelled',
|
||||
example: 'https://app.example.com/cancel'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cancelUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Discount code to apply',
|
||||
example: 'SUMMER2024'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
discountCode?: string;
|
||||
}
|
||||
|
||||
export class StripeCheckoutResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Stripe Checkout Session ID',
|
||||
example: 'cs_test_123456789'
|
||||
})
|
||||
@IsString()
|
||||
sessionId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Stripe Checkout URL',
|
||||
example: 'https://checkout.stripe.com/pay/cs_test_123456789'
|
||||
})
|
||||
@IsString()
|
||||
checkoutUrl: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Payment record ID',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
||||
})
|
||||
@IsUUID()
|
||||
paymentId: string;
|
||||
}
|
||||
|
||||
export class PaymentStatsDto {
|
||||
@ApiProperty({
|
||||
description: 'Total payments made by user'
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalPayments: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Total amount spent in cents'
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalAmountSpent: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current active plan'
|
||||
})
|
||||
@IsEnum(Plan)
|
||||
currentPlan: Plan;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Date of last successful payment'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
lastPaymentDate?: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Number of successful payments'
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
successfulPayments: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Number of failed payments'
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
failedPayments: number;
|
||||
}
|
||||
|
||||
// Plan pricing in cents
|
||||
export const PLAN_PRICING = {
|
||||
[Plan.BASIC]: 0, // Free plan
|
||||
[Plan.PRO]: 2999, // $29.99
|
||||
[Plan.MAX]: 4999, // $49.99
|
||||
} as const;
|
||||
|
||||
// Helper function to get plan pricing
|
||||
export function getPlanPrice(plan: Plan): number {
|
||||
return PLAN_PRICING[plan];
|
||||
}
|
||||
|
||||
// Helper function to format currency amount
|
||||
export function formatCurrencyAmount(amountInCents: number, currency: string = 'usd'): string {
|
||||
const amount = amountInCents / 100;
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
});
|
||||
return formatter.format(amount);
|
||||
}
|
||||
|
||||
// Helper function to validate plan upgrade
|
||||
export function isValidPlanUpgrade(currentPlan: Plan, newPlan: Plan): boolean {
|
||||
const planHierarchy = {
|
||||
[Plan.BASIC]: 0,
|
||||
[Plan.PRO]: 1,
|
||||
[Plan.MAX]: 2,
|
||||
};
|
||||
|
||||
return planHierarchy[newPlan] > planHierarchy[currentPlan];
|
||||
}
|
||||
|
||||
// Helper function to calculate proration amount
|
||||
export function calculateProrationAmount(
|
||||
currentPlan: Plan,
|
||||
newPlan: Plan,
|
||||
daysRemaining: number,
|
||||
totalDaysInPeriod: number = 30
|
||||
): number {
|
||||
if (!isValidPlanUpgrade(currentPlan, newPlan)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const currentPlanPrice = getPlanPrice(currentPlan);
|
||||
const newPlanPrice = getPlanPrice(newPlan);
|
||||
const priceDifference = newPlanPrice - currentPlanPrice;
|
||||
|
||||
// Calculate prorated amount for remaining days
|
||||
const prorationFactor = daysRemaining / totalDaysInPeriod;
|
||||
return Math.round(priceDifference * prorationFactor);
|
||||
}
|
203
packages/api/src/users/users.entity.ts
Normal file
203
packages/api/src/users/users.entity.ts
Normal file
|
@ -0,0 +1,203 @@
|
|||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Min,
|
||||
IsDate
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Plan } from '@prisma/client';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Google OAuth UID for OAuth integration',
|
||||
example: 'google_123456789'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
googleUid?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'User email address',
|
||||
example: 'user@example.com'
|
||||
})
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Hashed version of email for privacy',
|
||||
example: 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3'
|
||||
})
|
||||
@IsString()
|
||||
emailHash: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'User subscription plan',
|
||||
enum: Plan,
|
||||
default: Plan.BASIC
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(Plan)
|
||||
plan?: Plan;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Remaining quota for current period',
|
||||
example: 50,
|
||||
minimum: 0
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
quotaRemaining?: number;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'User subscription plan',
|
||||
enum: Plan
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(Plan)
|
||||
plan?: Plan;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Remaining quota for current period',
|
||||
minimum: 0
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
quotaRemaining?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Whether the user account is active'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UserResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Unique user identifier',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000'
|
||||
})
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Google OAuth UID',
|
||||
example: 'google_123456789'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
googleUid?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'User email address',
|
||||
example: 'user@example.com'
|
||||
})
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'User subscription plan',
|
||||
enum: Plan
|
||||
})
|
||||
@IsEnum(Plan)
|
||||
plan: Plan;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Remaining quota for current period',
|
||||
example: 50
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
quotaRemaining: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Date when quota resets'
|
||||
})
|
||||
@IsDate()
|
||||
quotaResetDate: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Whether the user account is active'
|
||||
})
|
||||
@IsBoolean()
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'User creation timestamp'
|
||||
})
|
||||
@IsDate()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'User last update timestamp'
|
||||
})
|
||||
@IsDate()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class UserStatsDto {
|
||||
@ApiProperty({
|
||||
description: 'Total number of batches processed'
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalBatches: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Total number of images processed'
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalImages: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current quota usage this period'
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
quotaUsed: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Total quota for current plan'
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalQuota: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Percentage of quota used'
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
quotaUsagePercentage: number;
|
||||
}
|
||||
|
||||
// Helper function to get quota limits by plan
|
||||
export function getQuotaLimitForPlan(plan: Plan): number {
|
||||
switch (plan) {
|
||||
case Plan.BASIC:
|
||||
return 50;
|
||||
case Plan.PRO:
|
||||
return 500;
|
||||
case Plan.MAX:
|
||||
return 1000;
|
||||
default:
|
||||
return 50;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to calculate quota reset date (monthly)
|
||||
export function calculateQuotaResetDate(): Date {
|
||||
const now = new Date();
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return nextMonth;
|
||||
}
|
47
packages/api/tsconfig.json
Normal file
47
packages/api/tsconfig.json
Normal file
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitOverride": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@/database/*": ["src/database/*"],
|
||||
"@/users/*": ["src/users/*"],
|
||||
"@/batches/*": ["src/batches/*"],
|
||||
"@/images/*": ["src/images/*"],
|
||||
"@/payments/*": ["src/payments/*"],
|
||||
"@/auth/*": ["src/auth/*"],
|
||||
"@/common/*": ["src/common/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"prisma/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"test",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue