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:
DustyWalker 2025-08-05 17:02:03 +02:00
parent 90016254a9
commit e7e09d5e2c
15 changed files with 3606 additions and 0 deletions

View file

@ -0,0 +1,227 @@
import {
IsString,
IsEnum,
IsInt,
IsOptional,
IsUUID,
IsObject,
Min,
IsDate
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BatchStatus } from '@prisma/client';
import { Type } from 'class-transformer';
export class CreateBatchDto {
@ApiProperty({
description: 'ID of the user creating the batch',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
userId: string;
@ApiPropertyOptional({
description: 'Total number of images in this batch',
example: 10,
minimum: 0
})
@IsOptional()
@IsInt()
@Min(0)
totalImages?: number;
@ApiPropertyOptional({
description: 'Additional metadata for the batch processing',
example: {
aiModel: 'gpt-4-vision',
processingOptions: { includeColors: true, includeTags: true }
}
})
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
export class UpdateBatchDto {
@ApiPropertyOptional({
description: 'Batch processing status',
enum: BatchStatus
})
@IsOptional()
@IsEnum(BatchStatus)
status?: BatchStatus;
@ApiPropertyOptional({
description: 'Total number of images in this batch',
minimum: 0
})
@IsOptional()
@IsInt()
@Min(0)
totalImages?: number;
@ApiPropertyOptional({
description: 'Number of processed images',
minimum: 0
})
@IsOptional()
@IsInt()
@Min(0)
processedImages?: number;
@ApiPropertyOptional({
description: 'Number of failed images',
minimum: 0
})
@IsOptional()
@IsInt()
@Min(0)
failedImages?: number;
@ApiPropertyOptional({
description: 'Additional metadata for the batch processing'
})
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
export class BatchResponseDto {
@ApiProperty({
description: 'Unique batch identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
id: string;
@ApiProperty({
description: 'ID of the user who owns this batch',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
userId: string;
@ApiProperty({
description: 'Current batch processing status',
enum: BatchStatus
})
@IsEnum(BatchStatus)
status: BatchStatus;
@ApiProperty({
description: 'Total number of images in this batch',
example: 10
})
@IsInt()
@Min(0)
totalImages: number;
@ApiProperty({
description: 'Number of processed images',
example: 8
})
@IsInt()
@Min(0)
processedImages: number;
@ApiProperty({
description: 'Number of failed images',
example: 1
})
@IsInt()
@Min(0)
failedImages: number;
@ApiPropertyOptional({
description: 'Additional metadata for the batch processing'
})
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
@ApiProperty({
description: 'Batch creation timestamp'
})
@IsDate()
createdAt: Date;
@ApiProperty({
description: 'Batch last update timestamp'
})
@IsDate()
updatedAt: Date;
@ApiPropertyOptional({
description: 'Batch completion timestamp'
})
@IsOptional()
@IsDate()
completedAt?: Date;
}
export class BatchStatsDto {
@ApiProperty({
description: 'Processing progress percentage',
example: 80
})
@IsInt()
@Min(0)
progressPercentage: number;
@ApiProperty({
description: 'Number of pending images',
example: 1
})
@IsInt()
@Min(0)
pendingImages: number;
@ApiProperty({
description: 'Average processing time per image in seconds',
example: 5.2
})
@Type(() => Number)
averageProcessingTime: number;
@ApiProperty({
description: 'Estimated time remaining in seconds',
example: 30
})
@Type(() => Number)
estimatedTimeRemaining: number;
}
export class BatchSummaryDto {
@ApiProperty({
description: 'Batch details'
})
batch: BatchResponseDto;
@ApiProperty({
description: 'Processing statistics'
})
stats: BatchStatsDto;
@ApiProperty({
description: 'Recent images from this batch (limited to 5)'
})
recentImages: Array<{
id: string;
originalName: string;
proposedName?: string;
status: string;
}>;
}
// Helper function to calculate progress percentage
export function calculateProgressPercentage(processedImages: number, totalImages: number): number {
if (totalImages === 0) return 0;
return Math.round((processedImages / totalImages) * 100);
}
// Helper function to determine if batch is complete
export function isBatchComplete(batch: { status: BatchStatus; processedImages: number; failedImages: number; totalImages: number }): boolean {
return batch.status === BatchStatus.DONE ||
batch.status === BatchStatus.ERROR ||
(batch.processedImages + batch.failedImages) >= batch.totalImages;
}

View file

@ -0,0 +1,27 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaService } from './prisma.service';
import { UserRepository } from './repositories/user.repository';
import { BatchRepository } from './repositories/batch.repository';
import { ImageRepository } from './repositories/image.repository';
import { PaymentRepository } from './repositories/payment.repository';
@Global()
@Module({
imports: [ConfigModule],
providers: [
PrismaService,
UserRepository,
BatchRepository,
ImageRepository,
PaymentRepository,
],
exports: [
PrismaService,
UserRepository,
BatchRepository,
ImageRepository,
PaymentRepository,
],
})
export class DatabaseModule {}

View file

@ -0,0 +1,138 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
constructor(private configService: ConfigService) {
super({
datasources: {
db: {
url: configService.get<string>('DATABASE_URL'),
},
},
log: [
{
emit: 'event',
level: 'query',
},
{
emit: 'event',
level: 'error',
},
{
emit: 'event',
level: 'info',
},
{
emit: 'event',
level: 'warn',
},
],
errorFormat: 'colorless',
});
// Log database queries in development
if (configService.get('NODE_ENV') === 'development') {
this.$on('query', (e) => {
this.logger.debug(`Query: ${e.query}`);
this.logger.debug(`Params: ${e.params}`);
this.logger.debug(`Duration: ${e.duration}ms`);
});
}
// Log database errors
this.$on('error', (e) => {
this.logger.error('Database error:', e);
});
// Log database info
this.$on('info', (e) => {
this.logger.log(`Database info: ${e.message}`);
});
// Log database warnings
this.$on('warn', (e) => {
this.logger.warn(`Database warning: ${e.message}`);
});
}
async onModuleInit() {
try {
await this.$connect();
this.logger.log('Successfully connected to database');
// Test the connection
await this.$queryRaw`SELECT 1`;
this.logger.log('Database connection test passed');
} catch (error) {
this.logger.error('Failed to connect to database:', error);
throw error;
}
}
async onModuleDestroy() {
try {
await this.$disconnect();
this.logger.log('Disconnected from database');
} catch (error) {
this.logger.error('Error during database disconnection:', error);
}
}
/**
* Clean shutdown method for graceful application termination
*/
async enableShutdownHooks() {
process.on('beforeExit', async () => {
await this.$disconnect();
});
}
/**
* Health check method to verify database connectivity
*/
async healthCheck(): Promise<boolean> {
try {
await this.$queryRaw`SELECT 1`;
return true;
} catch (error) {
this.logger.error('Database health check failed:', error);
return false;
}
}
/**
* Get database statistics
*/
async getDatabaseStats() {
try {
const [userCount, batchCount, imageCount, paymentCount] = await Promise.all([
this.user.count(),
this.batch.count(),
this.image.count(),
this.payment.count(),
]);
return {
users: userCount,
batches: batchCount,
images: imageCount,
payments: paymentCount,
timestamp: new Date(),
};
} catch (error) {
this.logger.error('Failed to get database stats:', error);
throw error;
}
}
/**
* Transaction helper method
*/
async transaction<T>(fn: (prisma: PrismaClient) => Promise<T>): Promise<T> {
return this.$transaction(fn);
}
}

View file

@ -0,0 +1,349 @@
import { Injectable, Logger } from '@nestjs/common';
import { Batch, BatchStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import { CreateBatchDto, UpdateBatchDto } from '../../batches/batch.entity';
@Injectable()
export class BatchRepository {
private readonly logger = new Logger(BatchRepository.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new batch
*/
async create(data: CreateBatchDto): Promise<Batch> {
try {
return await this.prisma.batch.create({
data: {
...data,
status: BatchStatus.PROCESSING,
},
});
} catch (error) {
this.logger.error('Failed to create batch:', error);
throw error;
}
}
/**
* Find batch by ID
*/
async findById(id: string): Promise<Batch | null> {
try {
return await this.prisma.batch.findUnique({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to find batch by ID ${id}:`, error);
throw error;
}
}
/**
* Update batch
*/
async update(id: string, data: UpdateBatchDto): Promise<Batch> {
try {
const updateData: any = { ...data };
// Set completedAt if status is changing to DONE or ERROR
if (data.status && (data.status === BatchStatus.DONE || data.status === BatchStatus.ERROR)) {
updateData.completedAt = new Date();
}
return await this.prisma.batch.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update batch ${id}:`, error);
throw error;
}
}
/**
* Delete batch
*/
async delete(id: string): Promise<Batch> {
try {
return await this.prisma.batch.delete({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to delete batch ${id}:`, error);
throw error;
}
}
/**
* Find batches with pagination
*/
async findMany(params: {
skip?: number;
take?: number;
where?: Prisma.BatchWhereInput;
orderBy?: Prisma.BatchOrderByWithRelationInput;
}): Promise<Batch[]> {
try {
return await this.prisma.batch.findMany({
skip: params.skip,
take: params.take,
where: params.where,
orderBy: params.orderBy,
});
} catch (error) {
this.logger.error('Failed to find batches:', error);
throw error;
}
}
/**
* Find batches by user ID
*/
async findByUserId(
userId: string,
params?: {
skip?: number;
take?: number;
status?: BatchStatus;
orderBy?: Prisma.BatchOrderByWithRelationInput;
}
): Promise<Batch[]> {
try {
return await this.prisma.batch.findMany({
where: {
userId,
...(params?.status && { status: params.status }),
},
skip: params?.skip,
take: params?.take,
orderBy: params?.orderBy || { createdAt: 'desc' },
});
} catch (error) {
this.logger.error(`Failed to find batches for user ${userId}:`, error);
throw error;
}
}
/**
* Count batches
*/
async count(where?: Prisma.BatchWhereInput): Promise<number> {
try {
return await this.prisma.batch.count({ where });
} catch (error) {
this.logger.error('Failed to count batches:', error);
throw error;
}
}
/**
* Find batch with images
*/
async findByIdWithImages(id: string): Promise<Batch & {
images: any[];
user: any;
_count: { images: number };
} | null> {
try {
return await this.prisma.batch.findUnique({
where: { id },
include: {
images: {
orderBy: { createdAt: 'asc' },
},
user: {
select: {
id: true,
email: true,
plan: true,
},
},
_count: {
select: { images: true },
},
},
});
} catch (error) {
this.logger.error(`Failed to find batch with images ${id}:`, error);
throw error;
}
}
/**
* Update batch progress
*/
async updateProgress(id: string, processedImages: number, failedImages: number): Promise<Batch> {
try {
const batch = await this.findById(id);
if (!batch) {
throw new Error(`Batch ${id} not found`);
}
// Determine if batch is complete
const totalProcessed = processedImages + failedImages;
const isComplete = totalProcessed >= batch.totalImages;
const updateData: any = {
processedImages,
failedImages,
};
if (isComplete) {
updateData.status = failedImages === batch.totalImages ? BatchStatus.ERROR : BatchStatus.DONE;
updateData.completedAt = new Date();
}
return await this.prisma.batch.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update batch progress ${id}:`, error);
throw error;
}
}
/**
* Increment processed images count
*/
async incrementProcessedImages(id: string): Promise<Batch> {
try {
return await this.prisma.batch.update({
where: { id },
data: {
processedImages: { increment: 1 },
},
});
} catch (error) {
this.logger.error(`Failed to increment processed images for batch ${id}:`, error);
throw error;
}
}
/**
* Increment failed images count
*/
async incrementFailedImages(id: string): Promise<Batch> {
try {
return await this.prisma.batch.update({
where: { id },
data: {
failedImages: { increment: 1 },
},
});
} catch (error) {
this.logger.error(`Failed to increment failed images for batch ${id}:`, error);
throw error;
}
}
/**
* Find processing batches (for cleanup/monitoring)
*/
async findProcessingBatches(olderThanMinutes?: number): Promise<Batch[]> {
try {
const where: Prisma.BatchWhereInput = {
status: BatchStatus.PROCESSING,
};
if (olderThanMinutes) {
const cutoffTime = new Date();
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
where.createdAt = { lte: cutoffTime };
}
return await this.prisma.batch.findMany({
where,
orderBy: { createdAt: 'asc' },
});
} catch (error) {
this.logger.error('Failed to find processing batches:', error);
throw error;
}
}
/**
* Get batch statistics
*/
async getBatchStats(batchId: string): Promise<{
totalImages: number;
processedImages: number;
failedImages: number;
pendingImages: number;
progressPercentage: number;
averageProcessingTime?: number;
}> {
try {
const batch = await this.findByIdWithImages(batchId);
if (!batch) {
throw new Error(`Batch ${batchId} not found`);
}
const pendingImages = batch.totalImages - batch.processedImages - batch.failedImages;
const progressPercentage = Math.round(
((batch.processedImages + batch.failedImages) / batch.totalImages) * 100
);
// Calculate average processing time from completed images
const completedImages = batch.images.filter(img => img.processedAt);
let averageProcessingTime: number | undefined;
if (completedImages.length > 0) {
const totalProcessingTime = completedImages.reduce((sum, img) => {
const processingTime = img.processedAt.getTime() - img.createdAt.getTime();
return sum + processingTime;
}, 0);
averageProcessingTime = totalProcessingTime / completedImages.length / 1000; // Convert to seconds
}
return {
totalImages: batch.totalImages,
processedImages: batch.processedImages,
failedImages: batch.failedImages,
pendingImages,
progressPercentage,
averageProcessingTime,
};
} catch (error) {
this.logger.error(`Failed to get batch stats for ${batchId}:`, error);
throw error;
}
}
/**
* Get user batch statistics
*/
async getUserBatchStats(userId: string): Promise<{
totalBatches: number;
completedBatches: number;
processingBatches: number;
errorBatches: number;
totalImages: number;
}> {
try {
const [totalBatches, completedBatches, processingBatches, errorBatches, imageStats] = await Promise.all([
this.count({ userId }),
this.count({ userId, status: BatchStatus.DONE }),
this.count({ userId, status: BatchStatus.PROCESSING }),
this.count({ userId, status: BatchStatus.ERROR }),
this.prisma.batch.aggregate({
where: { userId },
_sum: { totalImages: true },
}),
]);
return {
totalBatches,
completedBatches,
processingBatches,
errorBatches,
totalImages: imageStats._sum.totalImages || 0,
};
} catch (error) {
this.logger.error(`Failed to get user batch stats for ${userId}:`, error);
throw error;
}
}
}

View file

@ -0,0 +1,457 @@
import { Injectable, Logger } from '@nestjs/common';
import { Image, ImageStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import { CreateImageDto, UpdateImageDto } from '../../images/image.entity';
@Injectable()
export class ImageRepository {
private readonly logger = new Logger(ImageRepository.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new image
*/
async create(data: CreateImageDto): Promise<Image> {
try {
return await this.prisma.image.create({
data: {
...data,
status: ImageStatus.PENDING,
},
});
} catch (error) {
this.logger.error('Failed to create image:', error);
throw error;
}
}
/**
* Create multiple images in batch
*/
async createMany(images: CreateImageDto[]): Promise<{ count: number }> {
try {
const data = images.map(img => ({
...img,
status: ImageStatus.PENDING,
}));
return await this.prisma.image.createMany({
data,
skipDuplicates: true,
});
} catch (error) {
this.logger.error('Failed to create multiple images:', error);
throw error;
}
}
/**
* Find image by ID
*/
async findById(id: string): Promise<Image | null> {
try {
return await this.prisma.image.findUnique({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to find image by ID ${id}:`, error);
throw error;
}
}
/**
* Update image
*/
async update(id: string, data: UpdateImageDto): Promise<Image> {
try {
const updateData: any = { ...data };
// Set processedAt if status is changing to COMPLETED or FAILED
if (data.status && (data.status === ImageStatus.COMPLETED || data.status === ImageStatus.FAILED)) {
updateData.processedAt = new Date();
}
return await this.prisma.image.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update image ${id}:`, error);
throw error;
}
}
/**
* Delete image
*/
async delete(id: string): Promise<Image> {
try {
return await this.prisma.image.delete({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to delete image ${id}:`, error);
throw error;
}
}
/**
* Find images with pagination
*/
async findMany(params: {
skip?: number;
take?: number;
where?: Prisma.ImageWhereInput;
orderBy?: Prisma.ImageOrderByWithRelationInput;
}): Promise<Image[]> {
try {
return await this.prisma.image.findMany({
skip: params.skip,
take: params.take,
where: params.where,
orderBy: params.orderBy,
});
} catch (error) {
this.logger.error('Failed to find images:', error);
throw error;
}
}
/**
* Find images by batch ID
*/
async findByBatchId(
batchId: string,
params?: {
skip?: number;
take?: number;
status?: ImageStatus;
orderBy?: Prisma.ImageOrderByWithRelationInput;
}
): Promise<Image[]> {
try {
return await this.prisma.image.findMany({
where: {
batchId,
...(params?.status && { status: params.status }),
},
skip: params?.skip,
take: params?.take,
orderBy: params?.orderBy || { createdAt: 'asc' },
});
} catch (error) {
this.logger.error(`Failed to find images for batch ${batchId}:`, error);
throw error;
}
}
/**
* Count images
*/
async count(where?: Prisma.ImageWhereInput): Promise<number> {
try {
return await this.prisma.image.count({ where });
} catch (error) {
this.logger.error('Failed to count images:', error);
throw error;
}
}
/**
* Find image with batch info
*/
async findByIdWithBatch(id: string): Promise<Image & {
batch: any;
} | null> {
try {
return await this.prisma.image.findUnique({
where: { id },
include: {
batch: {
include: {
user: {
select: {
id: true,
email: true,
plan: true,
},
},
},
},
},
});
} catch (error) {
this.logger.error(`Failed to find image with batch ${id}:`, error);
throw error;
}
}
/**
* Update image status
*/
async updateStatus(id: string, status: ImageStatus, error?: string): Promise<Image> {
try {
const updateData: any = {
status,
...(error && { processingError: error }),
};
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
updateData.processedAt = new Date();
}
return await this.prisma.image.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update image status ${id}:`, error);
throw error;
}
}
/**
* Bulk update image statuses
*/
async bulkUpdateStatus(imageIds: string[], status: ImageStatus): Promise<{ count: number }> {
try {
const updateData: any = { status };
if (status === ImageStatus.COMPLETED || status === ImageStatus.FAILED) {
updateData.processedAt = new Date();
}
return await this.prisma.image.updateMany({
where: {
id: { in: imageIds },
},
data: updateData,
});
} catch (error) {
this.logger.error('Failed to bulk update image statuses:', error);
throw error;
}
}
/**
* Apply proposed names as final names
*/
async applyProposedNames(imageIds: string[]): Promise<{ count: number }> {
try {
// First, get all images with their proposed names
const images = await this.prisma.image.findMany({
where: {
id: { in: imageIds },
proposedName: { not: null },
},
select: { id: true, proposedName: true },
});
// Use transaction to update each image with its proposed name as final name
const results = await this.prisma.$transaction(
images.map(image =>
this.prisma.image.update({
where: { id: image.id },
data: { finalName: image.proposedName },
})
)
);
return { count: results.length };
} catch (error) {
this.logger.error('Failed to apply proposed names:', error);
throw error;
}
}
/**
* Find pending images for processing
*/
async findPendingImages(limit?: number): Promise<Image[]> {
try {
return await this.prisma.image.findMany({
where: {
status: ImageStatus.PENDING,
},
orderBy: { createdAt: 'asc' },
take: limit,
include: {
batch: {
include: {
user: {
select: {
id: true,
email: true,
plan: true,
},
},
},
},
},
});
} catch (error) {
this.logger.error('Failed to find pending images:', error);
throw error;
}
}
/**
* Find processing images (for cleanup/monitoring)
*/
async findProcessingImages(olderThanMinutes?: number): Promise<Image[]> {
try {
const where: Prisma.ImageWhereInput = {
status: ImageStatus.PROCESSING,
};
if (olderThanMinutes) {
const cutoffTime = new Date();
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
where.updatedAt = { lte: cutoffTime };
}
return await this.prisma.image.findMany({
where,
orderBy: { updatedAt: 'asc' },
});
} catch (error) {
this.logger.error('Failed to find processing images:', error);
throw error;
}
}
/**
* Get image processing statistics for a batch
*/
async getBatchImageStats(batchId: string): Promise<{
total: number;
pending: number;
processing: number;
completed: number;
failed: number;
}> {
try {
const [total, pending, processing, completed, failed] = await Promise.all([
this.count({ batchId }),
this.count({ batchId, status: ImageStatus.PENDING }),
this.count({ batchId, status: ImageStatus.PROCESSING }),
this.count({ batchId, status: ImageStatus.COMPLETED }),
this.count({ batchId, status: ImageStatus.FAILED }),
]);
return {
total,
pending,
processing,
completed,
failed,
};
} catch (error) {
this.logger.error(`Failed to get batch image stats for ${batchId}:`, error);
throw error;
}
}
/**
* Get user image processing statistics
*/
async getUserImageStats(userId: string): Promise<{
totalImages: number;
completedImages: number;
failedImages: number;
processingImages: number;
pendingImages: number;
}> {
try {
const [
totalImages,
completedImages,
failedImages,
processingImages,
pendingImages,
] = await Promise.all([
this.prisma.image.count({
where: {
batch: { userId },
},
}),
this.prisma.image.count({
where: {
batch: { userId },
status: ImageStatus.COMPLETED,
},
}),
this.prisma.image.count({
where: {
batch: { userId },
status: ImageStatus.FAILED,
},
}),
this.prisma.image.count({
where: {
batch: { userId },
status: ImageStatus.PROCESSING,
},
}),
this.prisma.image.count({
where: {
batch: { userId },
status: ImageStatus.PENDING,
},
}),
]);
return {
totalImages,
completedImages,
failedImages,
processingImages,
pendingImages,
};
} catch (error) {
this.logger.error(`Failed to get user image stats for ${userId}:`, error);
throw error;
}
}
/**
* Search images by original name
*/
async searchByOriginalName(
searchTerm: string,
userId?: string,
params?: { skip?: number; take?: number }
): Promise<Image[]> {
try {
const where: Prisma.ImageWhereInput = {
originalName: {
contains: searchTerm,
mode: 'insensitive',
},
...(userId && {
batch: { userId },
}),
};
return await this.prisma.image.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: params?.skip,
take: params?.take,
include: {
batch: {
select: {
id: true,
status: true,
createdAt: true,
},
},
},
});
} catch (error) {
this.logger.error('Failed to search images by original name:', error);
throw error;
}
}
}

View file

@ -0,0 +1,437 @@
import { Injectable, Logger } from '@nestjs/common';
import { Payment, PaymentStatus, Plan, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import { CreatePaymentDto, UpdatePaymentDto } from '../../payments/payment.entity';
@Injectable()
export class PaymentRepository {
private readonly logger = new Logger(PaymentRepository.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new payment
*/
async create(data: CreatePaymentDto): Promise<Payment> {
try {
return await this.prisma.payment.create({
data: {
...data,
status: PaymentStatus.PENDING,
},
});
} catch (error) {
this.logger.error('Failed to create payment:', error);
throw error;
}
}
/**
* Find payment by ID
*/
async findById(id: string): Promise<Payment | null> {
try {
return await this.prisma.payment.findUnique({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to find payment by ID ${id}:`, error);
throw error;
}
}
/**
* Find payment by Stripe Session ID
*/
async findByStripeSessionId(stripeSessionId: string): Promise<Payment | null> {
try {
return await this.prisma.payment.findUnique({
where: { stripeSessionId },
});
} catch (error) {
this.logger.error(`Failed to find payment by Stripe Session ID ${stripeSessionId}:`, error);
throw error;
}
}
/**
* Find payment by Stripe Payment ID
*/
async findByStripePaymentId(stripePaymentId: string): Promise<Payment | null> {
try {
return await this.prisma.payment.findUnique({
where: { stripePaymentId },
});
} catch (error) {
this.logger.error(`Failed to find payment by Stripe Payment ID ${stripePaymentId}:`, error);
throw error;
}
}
/**
* Update payment
*/
async update(id: string, data: UpdatePaymentDto): Promise<Payment> {
try {
const updateData: any = { ...data };
// Set paidAt if status is changing to COMPLETED
if (data.status === PaymentStatus.COMPLETED) {
updateData.paidAt = new Date();
}
return await this.prisma.payment.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update payment ${id}:`, error);
throw error;
}
}
/**
* Delete payment
*/
async delete(id: string): Promise<Payment> {
try {
return await this.prisma.payment.delete({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to delete payment ${id}:`, error);
throw error;
}
}
/**
* Find payments with pagination
*/
async findMany(params: {
skip?: number;
take?: number;
where?: Prisma.PaymentWhereInput;
orderBy?: Prisma.PaymentOrderByWithRelationInput;
}): Promise<Payment[]> {
try {
return await this.prisma.payment.findMany({
skip: params.skip,
take: params.take,
where: params.where,
orderBy: params.orderBy,
});
} catch (error) {
this.logger.error('Failed to find payments:', error);
throw error;
}
}
/**
* Find payments by user ID
*/
async findByUserId(
userId: string,
params?: {
skip?: number;
take?: number;
status?: PaymentStatus;
orderBy?: Prisma.PaymentOrderByWithRelationInput;
}
): Promise<Payment[]> {
try {
return await this.prisma.payment.findMany({
where: {
userId,
...(params?.status && { status: params.status }),
},
skip: params?.skip,
take: params?.take,
orderBy: params?.orderBy || { createdAt: 'desc' },
});
} catch (error) {
this.logger.error(`Failed to find payments for user ${userId}:`, error);
throw error;
}
}
/**
* Count payments
*/
async count(where?: Prisma.PaymentWhereInput): Promise<number> {
try {
return await this.prisma.payment.count({ where });
} catch (error) {
this.logger.error('Failed to count payments:', error);
throw error;
}
}
/**
* Find payment with user info
*/
async findByIdWithUser(id: string): Promise<Payment & {
user: any;
} | null> {
try {
return await this.prisma.payment.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
email: true,
plan: true,
quotaRemaining: true,
},
},
},
});
} catch (error) {
this.logger.error(`Failed to find payment with user ${id}:`, error);
throw error;
}
}
/**
* Update payment status
*/
async updateStatus(id: string, status: PaymentStatus, stripePaymentId?: string): Promise<Payment> {
try {
const updateData: any = { status };
if (stripePaymentId) {
updateData.stripePaymentId = stripePaymentId;
}
if (status === PaymentStatus.COMPLETED) {
updateData.paidAt = new Date();
}
return await this.prisma.payment.update({
where: { id },
data: updateData,
});
} catch (error) {
this.logger.error(`Failed to update payment status ${id}:`, error);
throw error;
}
}
/**
* Find successful payments by user
*/
async findSuccessfulPaymentsByUserId(userId: string): Promise<Payment[]> {
try {
return await this.prisma.payment.findMany({
where: {
userId,
status: PaymentStatus.COMPLETED,
},
orderBy: { paidAt: 'desc' },
});
} catch (error) {
this.logger.error(`Failed to find successful payments for user ${userId}:`, error);
throw error;
}
}
/**
* Get user payment statistics
*/
async getUserPaymentStats(userId: string): Promise<{
totalPayments: number;
successfulPayments: number;
failedPayments: number;
totalAmountSpent: number;
lastPaymentDate?: Date;
averagePaymentAmount: number;
}> {
try {
const [
totalPayments,
successfulPayments,
failedPayments,
amountStats,
lastSuccessfulPayment,
] = await Promise.all([
this.count({ userId }),
this.count({ userId, status: PaymentStatus.COMPLETED }),
this.count({
userId,
status: { in: [PaymentStatus.FAILED, PaymentStatus.CANCELLED] }
}),
this.prisma.payment.aggregate({
where: {
userId,
status: PaymentStatus.COMPLETED
},
_sum: { amount: true },
_avg: { amount: true },
}),
this.prisma.payment.findFirst({
where: {
userId,
status: PaymentStatus.COMPLETED
},
orderBy: { paidAt: 'desc' },
select: { paidAt: true },
}),
]);
return {
totalPayments,
successfulPayments,
failedPayments,
totalAmountSpent: amountStats._sum.amount || 0,
lastPaymentDate: lastSuccessfulPayment?.paidAt || undefined,
averagePaymentAmount: Math.round(amountStats._avg.amount || 0),
};
} catch (error) {
this.logger.error(`Failed to get user payment stats for ${userId}:`, error);
throw error;
}
}
/**
* Find pending payments (for cleanup/monitoring)
*/
async findPendingPayments(olderThanMinutes?: number): Promise<Payment[]> {
try {
const where: Prisma.PaymentWhereInput = {
status: PaymentStatus.PENDING,
};
if (olderThanMinutes) {
const cutoffTime = new Date();
cutoffTime.setMinutes(cutoffTime.getMinutes() - olderThanMinutes);
where.createdAt = { lte: cutoffTime };
}
return await this.prisma.payment.findMany({
where,
orderBy: { createdAt: 'asc' },
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
} catch (error) {
this.logger.error('Failed to find pending payments:', error);
throw error;
}
}
/**
* Get revenue statistics
*/
async getRevenueStats(params?: {
startDate?: Date;
endDate?: Date;
plan?: Plan;
}): Promise<{
totalRevenue: number;
totalPayments: number;
averagePaymentAmount: number;
revenueByPlan: Record<Plan, number>;
paymentsCount: Record<PaymentStatus, number>;
}> {
try {
const where: Prisma.PaymentWhereInput = {
status: PaymentStatus.COMPLETED,
...(params?.startDate && { createdAt: { gte: params.startDate } }),
...(params?.endDate && { createdAt: { lte: params.endDate } }),
...(params?.plan && { plan: params.plan }),
};
const [revenueStats, revenueByPlan, paymentStatusCounts] = await Promise.all([
this.prisma.payment.aggregate({
where,
_sum: { amount: true },
_count: true,
_avg: { amount: true },
}),
this.prisma.payment.groupBy({
by: ['plan'],
where,
_sum: { amount: true },
}),
this.prisma.payment.groupBy({
by: ['status'],
_count: true,
}),
]);
const revenueByPlanMap = Object.values(Plan).reduce((acc, plan) => {
acc[plan] = 0;
return acc;
}, {} as Record<Plan, number>);
revenueByPlan.forEach(item => {
revenueByPlanMap[item.plan] = item._sum.amount || 0;
});
const paymentsCountMap = Object.values(PaymentStatus).reduce((acc, status) => {
acc[status] = 0;
return acc;
}, {} as Record<PaymentStatus, number>);
paymentStatusCounts.forEach(item => {
paymentsCountMap[item.status] = item._count;
});
return {
totalRevenue: revenueStats._sum.amount || 0,
totalPayments: revenueStats._count,
averagePaymentAmount: Math.round(revenueStats._avg.amount || 0),
revenueByPlan: revenueByPlanMap,
paymentsCount: paymentsCountMap,
};
} catch (error) {
this.logger.error('Failed to get revenue stats:', error);
throw error;
}
}
/**
* Find payments by date range
*/
async findPaymentsByDateRange(
startDate: Date,
endDate: Date,
params?: {
userId?: string;
status?: PaymentStatus;
plan?: Plan;
}
): Promise<Payment[]> {
try {
return await this.prisma.payment.findMany({
where: {
createdAt: {
gte: startDate,
lte: endDate,
},
...(params?.userId && { userId: params.userId }),
...(params?.status && { status: params.status }),
...(params?.plan && { plan: params.plan }),
},
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
} catch (error) {
this.logger.error('Failed to find payments by date range:', error);
throw error;
}
}
}

View file

@ -0,0 +1,309 @@
import { Injectable, Logger } from '@nestjs/common';
import { User, Plan, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import { CreateUserDto, UpdateUserDto } from '../../users/users.entity';
@Injectable()
export class UserRepository {
private readonly logger = new Logger(UserRepository.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new user
*/
async create(data: CreateUserDto): Promise<User> {
try {
return await this.prisma.user.create({
data: {
...data,
plan: data.plan || Plan.BASIC,
quotaRemaining: data.quotaRemaining || this.getQuotaForPlan(data.plan || Plan.BASIC),
},
});
} catch (error) {
this.logger.error('Failed to create user:', error);
throw error;
}
}
/**
* Find user by ID
*/
async findById(id: string): Promise<User | null> {
try {
return await this.prisma.user.findUnique({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to find user by ID ${id}:`, error);
throw error;
}
}
/**
* Find user by email
*/
async findByEmail(email: string): Promise<User | null> {
try {
return await this.prisma.user.findUnique({
where: { email },
});
} catch (error) {
this.logger.error(`Failed to find user by email ${email}:`, error);
throw error;
}
}
/**
* Find user by Google UID
*/
async findByGoogleUid(googleUid: string): Promise<User | null> {
try {
return await this.prisma.user.findUnique({
where: { googleUid },
});
} catch (error) {
this.logger.error(`Failed to find user by Google UID ${googleUid}:`, error);
throw error;
}
}
/**
* Find user by email hash
*/
async findByEmailHash(emailHash: string): Promise<User | null> {
try {
return await this.prisma.user.findUnique({
where: { emailHash },
});
} catch (error) {
this.logger.error(`Failed to find user by email hash:`, error);
throw error;
}
}
/**
* Update user
*/
async update(id: string, data: UpdateUserDto): Promise<User> {
try {
return await this.prisma.user.update({
where: { id },
data,
});
} catch (error) {
this.logger.error(`Failed to update user ${id}:`, error);
throw error;
}
}
/**
* Delete user
*/
async delete(id: string): Promise<User> {
try {
return await this.prisma.user.delete({
where: { id },
});
} catch (error) {
this.logger.error(`Failed to delete user ${id}:`, error);
throw error;
}
}
/**
* Find users with pagination
*/
async findMany(params: {
skip?: number;
take?: number;
where?: Prisma.UserWhereInput;
orderBy?: Prisma.UserOrderByWithRelationInput;
}): Promise<User[]> {
try {
return await this.prisma.user.findMany({
skip: params.skip,
take: params.take,
where: params.where,
orderBy: params.orderBy,
});
} catch (error) {
this.logger.error('Failed to find users:', error);
throw error;
}
}
/**
* Count users
*/
async count(where?: Prisma.UserWhereInput): Promise<number> {
try {
return await this.prisma.user.count({ where });
} catch (error) {
this.logger.error('Failed to count users:', error);
throw error;
}
}
/**
* Update user quota
*/
async updateQuota(id: string, quotaRemaining: number): Promise<User> {
try {
return await this.prisma.user.update({
where: { id },
data: { quotaRemaining },
});
} catch (error) {
this.logger.error(`Failed to update quota for user ${id}:`, error);
throw error;
}
}
/**
* Deduct quota from user
*/
async deductQuota(id: string, amount: number): Promise<User> {
try {
return await this.prisma.user.update({
where: { id },
data: {
quotaRemaining: {
decrement: amount,
},
},
});
} catch (error) {
this.logger.error(`Failed to deduct quota for user ${id}:`, error);
throw error;
}
}
/**
* Reset user quota (monthly reset)
*/
async resetQuota(id: string): Promise<User> {
try {
const user = await this.findById(id);
if (!user) {
throw new Error(`User ${id} not found`);
}
const newQuota = this.getQuotaForPlan(user.plan);
const nextResetDate = this.calculateNextResetDate();
return await this.prisma.user.update({
where: { id },
data: {
quotaRemaining: newQuota,
quotaResetDate: nextResetDate,
},
});
} catch (error) {
this.logger.error(`Failed to reset quota for user ${id}:`, error);
throw error;
}
}
/**
* Upgrade user plan
*/
async upgradePlan(id: string, newPlan: Plan): Promise<User> {
try {
const newQuota = this.getQuotaForPlan(newPlan);
return await this.prisma.user.update({
where: { id },
data: {
plan: newPlan,
quotaRemaining: newQuota,
quotaResetDate: this.calculateNextResetDate(),
},
});
} catch (error) {
this.logger.error(`Failed to upgrade plan for user ${id}:`, error);
throw error;
}
}
/**
* Find users with expired quotas
*/
async findUsersWithExpiredQuotas(): Promise<User[]> {
try {
return await this.prisma.user.findMany({
where: {
quotaResetDate: {
lte: new Date(),
},
isActive: true,
},
});
} catch (error) {
this.logger.error('Failed to find users with expired quotas:', error);
throw error;
}
}
/**
* Get user with related data
*/
async findByIdWithRelations(id: string): Promise<User & {
batches: any[];
payments: any[];
_count: {
batches: number;
payments: number;
};
} | null> {
try {
return await this.prisma.user.findUnique({
where: { id },
include: {
batches: {
orderBy: { createdAt: 'desc' },
take: 5,
},
payments: {
orderBy: { createdAt: 'desc' },
take: 5,
},
_count: {
select: {
batches: true,
payments: true,
},
},
},
});
} catch (error) {
this.logger.error(`Failed to find user with relations ${id}:`, error);
throw error;
}
}
/**
* Helper: Get quota for plan
*/
private getQuotaForPlan(plan: Plan): number {
switch (plan) {
case Plan.BASIC:
return 50;
case Plan.PRO:
return 500;
case Plan.MAX:
return 1000;
default:
return 50;
}
}
/**
* Helper: Calculate next quota reset date (first day of next month)
*/
private calculateNextResetDate(): Date {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() + 1, 1);
}
}

View file

@ -0,0 +1,349 @@
import {
IsString,
IsEnum,
IsInt,
IsOptional,
IsUUID,
IsObject,
MinLength,
MaxLength,
Min,
IsDate
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ImageStatus } from '@prisma/client';
import { Type } from 'class-transformer';
export interface VisionTagsInterface {
objects?: string[];
colors?: string[];
scene?: string;
description?: string;
confidence?: number;
aiModel?: string;
processingTime?: number;
}
export interface ImageDimensionsInterface {
width: number;
height: number;
aspectRatio?: string;
}
export class CreateImageDto {
@ApiProperty({
description: 'ID of the batch this image belongs to',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
batchId: string;
@ApiProperty({
description: 'Original filename of the image',
example: 'IMG_20240101_123456.jpg'
})
@IsString()
@MinLength(1)
@MaxLength(255)
originalName: string;
@ApiPropertyOptional({
description: 'File size in bytes',
example: 2048576
})
@IsOptional()
@IsInt()
@Min(0)
fileSize?: number;
@ApiPropertyOptional({
description: 'MIME type of the image',
example: 'image/jpeg'
})
@IsOptional()
@IsString()
mimeType?: string;
@ApiPropertyOptional({
description: 'Image dimensions',
example: { width: 1920, height: 1080, aspectRatio: '16:9' }
})
@IsOptional()
@IsObject()
dimensions?: ImageDimensionsInterface;
@ApiPropertyOptional({
description: 'S3 object key for storage',
example: 'uploads/user123/batch456/original/image.jpg'
})
@IsOptional()
@IsString()
s3Key?: string;
}
export class UpdateImageDto {
@ApiPropertyOptional({
description: 'AI-generated proposed filename',
example: 'modern-kitchen-with-stainless-steel-appliances.jpg'
})
@IsOptional()
@IsString()
@MaxLength(255)
proposedName?: string;
@ApiPropertyOptional({
description: 'User-approved final filename',
example: 'kitchen-renovation-final.jpg'
})
@IsOptional()
@IsString()
@MaxLength(255)
finalName?: string;
@ApiPropertyOptional({
description: 'AI vision analysis results',
example: {
objects: ['kitchen', 'refrigerator', 'countertop'],
colors: ['white', 'stainless steel', 'black'],
scene: 'modern kitchen interior',
description: 'A modern kitchen with stainless steel appliances',
confidence: 0.95,
aiModel: 'gpt-4-vision',
processingTime: 2.5
}
})
@IsOptional()
@IsObject()
visionTags?: VisionTagsInterface;
@ApiPropertyOptional({
description: 'Image processing status',
enum: ImageStatus
})
@IsOptional()
@IsEnum(ImageStatus)
status?: ImageStatus;
@ApiPropertyOptional({
description: 'Error message if processing failed',
example: 'Image format not supported'
})
@IsOptional()
@IsString()
@MaxLength(500)
processingError?: string;
}
export class ImageResponseDto {
@ApiProperty({
description: 'Unique image identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
id: string;
@ApiProperty({
description: 'ID of the batch this image belongs to',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
batchId: string;
@ApiProperty({
description: 'Original filename of the image',
example: 'IMG_20240101_123456.jpg'
})
@IsString()
originalName: string;
@ApiPropertyOptional({
description: 'AI-generated proposed filename',
example: 'modern-kitchen-with-stainless-steel-appliances.jpg'
})
@IsOptional()
@IsString()
proposedName?: string;
@ApiPropertyOptional({
description: 'User-approved final filename',
example: 'kitchen-renovation-final.jpg'
})
@IsOptional()
@IsString()
finalName?: string;
@ApiPropertyOptional({
description: 'AI vision analysis results'
})
@IsOptional()
@IsObject()
visionTags?: VisionTagsInterface;
@ApiProperty({
description: 'Current image processing status',
enum: ImageStatus
})
@IsEnum(ImageStatus)
status: ImageStatus;
@ApiPropertyOptional({
description: 'File size in bytes',
example: 2048576
})
@IsOptional()
@IsInt()
fileSize?: number;
@ApiPropertyOptional({
description: 'Image dimensions'
})
@IsOptional()
@IsObject()
dimensions?: ImageDimensionsInterface;
@ApiPropertyOptional({
description: 'MIME type of the image',
example: 'image/jpeg'
})
@IsOptional()
@IsString()
mimeType?: string;
@ApiPropertyOptional({
description: 'S3 object key for storage',
example: 'uploads/user123/batch456/original/image.jpg'
})
@IsOptional()
@IsString()
s3Key?: string;
@ApiPropertyOptional({
description: 'Error message if processing failed'
})
@IsOptional()
@IsString()
processingError?: string;
@ApiProperty({
description: 'Image creation timestamp'
})
@IsDate()
createdAt: Date;
@ApiProperty({
description: 'Image last update timestamp'
})
@IsDate()
updatedAt: Date;
@ApiPropertyOptional({
description: 'Image processing completion timestamp'
})
@IsOptional()
@IsDate()
processedAt?: Date;
}
export class ImageProcessingResultDto {
@ApiProperty({
description: 'Image details'
})
image: ImageResponseDto;
@ApiProperty({
description: 'Processing success status'
})
success: boolean;
@ApiPropertyOptional({
description: 'Processing time in seconds'
})
@IsOptional()
@Type(() => Number)
processingTime?: number;
@ApiPropertyOptional({
description: 'Error details if processing failed'
})
@IsOptional()
@IsString()
error?: string;
}
export class BulkImageUpdateDto {
@ApiProperty({
description: 'Array of image IDs to update',
example: ['550e8400-e29b-41d4-a716-446655440000', '660f9511-f39c-52e5-b827-557766551111']
})
@IsUUID(undefined, { each: true })
imageIds: string[];
@ApiPropertyOptional({
description: 'Status to set for all images',
enum: ImageStatus
})
@IsOptional()
@IsEnum(ImageStatus)
status?: ImageStatus;
@ApiPropertyOptional({
description: 'Apply proposed names as final names for all images'
})
@IsOptional()
applyProposedNames?: boolean;
}
// Helper function to generate SEO-friendly filename
export function generateSeoFriendlyFilename(
visionTags: VisionTagsInterface,
originalName: string
): string {
if (!visionTags.objects && !visionTags.description) {
return originalName;
}
let filename = '';
// Use description if available, otherwise use objects
if (visionTags.description) {
filename = visionTags.description
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.substring(0, 100); // Limit length
} else if (visionTags.objects && visionTags.objects.length > 0) {
filename = visionTags.objects
.slice(0, 3) // Take first 3 objects
.join('-')
.toLowerCase()
.replace(/[^a-z0-9-]/g, '')
.substring(0, 100);
}
// Get file extension from original name
const extension = originalName.split('.').pop()?.toLowerCase() || 'jpg';
return filename ? `${filename}.${extension}` : originalName;
}
// Helper function to validate image file type
export function isValidImageType(mimeType: string): boolean {
const validTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
'image/gif',
'image/bmp',
'image/tiff'
];
return validTypes.includes(mimeType.toLowerCase());
}
// Helper function to calculate aspect ratio
export function calculateAspectRatio(width: number, height: number): string {
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
const divisor = gcd(width, height);
return `${width / divisor}:${height / divisor}`;
}

View file

@ -0,0 +1,344 @@
import {
IsString,
IsEnum,
IsInt,
IsOptional,
IsUUID,
IsObject,
Min,
IsDate,
Length
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Plan, PaymentStatus } from '@prisma/client';
import { Type } from 'class-transformer';
export interface PaymentMetadataInterface {
stripeCustomerId?: string;
subscriptionId?: string;
priceId?: string;
previousPlan?: Plan;
upgradeReason?: string;
discountCode?: string;
discountAmount?: number;
tax?: {
amount: number;
rate: number;
country: string;
};
}
export class CreatePaymentDto {
@ApiProperty({
description: 'ID of the user making the payment',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
userId: string;
@ApiProperty({
description: 'Plan being purchased',
enum: Plan,
example: Plan.PRO
})
@IsEnum(Plan)
plan: Plan;
@ApiProperty({
description: 'Payment amount in cents',
example: 2999,
minimum: 0
})
@IsInt()
@Min(0)
amount: number;
@ApiPropertyOptional({
description: 'Payment currency',
example: 'usd',
default: 'usd'
})
@IsOptional()
@IsString()
@Length(3, 3)
currency?: string;
@ApiPropertyOptional({
description: 'Stripe Checkout Session ID',
example: 'cs_test_123456789'
})
@IsOptional()
@IsString()
stripeSessionId?: string;
@ApiPropertyOptional({
description: 'Additional payment metadata'
})
@IsOptional()
@IsObject()
metadata?: PaymentMetadataInterface;
}
export class UpdatePaymentDto {
@ApiPropertyOptional({
description: 'Payment status',
enum: PaymentStatus
})
@IsOptional()
@IsEnum(PaymentStatus)
status?: PaymentStatus;
@ApiPropertyOptional({
description: 'Stripe Payment Intent ID',
example: 'pi_123456789'
})
@IsOptional()
@IsString()
stripePaymentId?: string;
@ApiPropertyOptional({
description: 'Additional payment metadata'
})
@IsOptional()
@IsObject()
metadata?: PaymentMetadataInterface;
}
export class PaymentResponseDto {
@ApiProperty({
description: 'Unique payment identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
id: string;
@ApiProperty({
description: 'ID of the user who made the payment',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
userId: string;
@ApiPropertyOptional({
description: 'Stripe Checkout Session ID',
example: 'cs_test_123456789'
})
@IsOptional()
@IsString()
stripeSessionId?: string;
@ApiPropertyOptional({
description: 'Stripe Payment Intent ID',
example: 'pi_123456789'
})
@IsOptional()
@IsString()
stripePaymentId?: string;
@ApiProperty({
description: 'Plan that was purchased',
enum: Plan
})
@IsEnum(Plan)
plan: Plan;
@ApiProperty({
description: 'Payment amount in cents',
example: 2999
})
@IsInt()
@Min(0)
amount: number;
@ApiProperty({
description: 'Payment currency',
example: 'usd'
})
@IsString()
currency: string;
@ApiProperty({
description: 'Current payment status',
enum: PaymentStatus
})
@IsEnum(PaymentStatus)
status: PaymentStatus;
@ApiPropertyOptional({
description: 'Additional payment metadata'
})
@IsOptional()
@IsObject()
metadata?: PaymentMetadataInterface;
@ApiProperty({
description: 'Payment creation timestamp'
})
@IsDate()
createdAt: Date;
@ApiProperty({
description: 'Payment last update timestamp'
})
@IsDate()
updatedAt: Date;
@ApiPropertyOptional({
description: 'Payment completion timestamp'
})
@IsOptional()
@IsDate()
paidAt?: Date;
}
export class StripeCheckoutSessionDto {
@ApiProperty({
description: 'Plan to purchase',
enum: Plan
})
@IsEnum(Plan)
plan: Plan;
@ApiPropertyOptional({
description: 'Success URL after payment',
example: 'https://app.example.com/success'
})
@IsOptional()
@IsString()
successUrl?: string;
@ApiPropertyOptional({
description: 'Cancel URL if payment is cancelled',
example: 'https://app.example.com/cancel'
})
@IsOptional()
@IsString()
cancelUrl?: string;
@ApiPropertyOptional({
description: 'Discount code to apply',
example: 'SUMMER2024'
})
@IsOptional()
@IsString()
discountCode?: string;
}
export class StripeCheckoutResponseDto {
@ApiProperty({
description: 'Stripe Checkout Session ID',
example: 'cs_test_123456789'
})
@IsString()
sessionId: string;
@ApiProperty({
description: 'Stripe Checkout URL',
example: 'https://checkout.stripe.com/pay/cs_test_123456789'
})
@IsString()
checkoutUrl: string;
@ApiProperty({
description: 'Payment record ID',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
paymentId: string;
}
export class PaymentStatsDto {
@ApiProperty({
description: 'Total payments made by user'
})
@IsInt()
@Min(0)
totalPayments: number;
@ApiProperty({
description: 'Total amount spent in cents'
})
@IsInt()
@Min(0)
totalAmountSpent: number;
@ApiProperty({
description: 'Current active plan'
})
@IsEnum(Plan)
currentPlan: Plan;
@ApiProperty({
description: 'Date of last successful payment'
})
@IsOptional()
@IsDate()
lastPaymentDate?: Date;
@ApiProperty({
description: 'Number of successful payments'
})
@IsInt()
@Min(0)
successfulPayments: number;
@ApiProperty({
description: 'Number of failed payments'
})
@IsInt()
@Min(0)
failedPayments: number;
}
// Plan pricing in cents
export const PLAN_PRICING = {
[Plan.BASIC]: 0, // Free plan
[Plan.PRO]: 2999, // $29.99
[Plan.MAX]: 4999, // $49.99
} as const;
// Helper function to get plan pricing
export function getPlanPrice(plan: Plan): number {
return PLAN_PRICING[plan];
}
// Helper function to format currency amount
export function formatCurrencyAmount(amountInCents: number, currency: string = 'usd'): string {
const amount = amountInCents / 100;
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
});
return formatter.format(amount);
}
// Helper function to validate plan upgrade
export function isValidPlanUpgrade(currentPlan: Plan, newPlan: Plan): boolean {
const planHierarchy = {
[Plan.BASIC]: 0,
[Plan.PRO]: 1,
[Plan.MAX]: 2,
};
return planHierarchy[newPlan] > planHierarchy[currentPlan];
}
// Helper function to calculate proration amount
export function calculateProrationAmount(
currentPlan: Plan,
newPlan: Plan,
daysRemaining: number,
totalDaysInPeriod: number = 30
): number {
if (!isValidPlanUpgrade(currentPlan, newPlan)) {
return 0;
}
const currentPlanPrice = getPlanPrice(currentPlan);
const newPlanPrice = getPlanPrice(newPlan);
const priceDifference = newPlanPrice - currentPlanPrice;
// Calculate prorated amount for remaining days
const prorationFactor = daysRemaining / totalDaysInPeriod;
return Math.round(priceDifference * prorationFactor);
}

View file

@ -0,0 +1,203 @@
import {
IsEmail,
IsString,
IsEnum,
IsInt,
IsBoolean,
IsOptional,
IsUUID,
Min,
IsDate
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Plan } from '@prisma/client';
export class CreateUserDto {
@ApiPropertyOptional({
description: 'Google OAuth UID for OAuth integration',
example: 'google_123456789'
})
@IsOptional()
@IsString()
googleUid?: string;
@ApiProperty({
description: 'User email address',
example: 'user@example.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'Hashed version of email for privacy',
example: 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3'
})
@IsString()
emailHash: string;
@ApiPropertyOptional({
description: 'User subscription plan',
enum: Plan,
default: Plan.BASIC
})
@IsOptional()
@IsEnum(Plan)
plan?: Plan;
@ApiPropertyOptional({
description: 'Remaining quota for current period',
example: 50,
minimum: 0
})
@IsOptional()
@IsInt()
@Min(0)
quotaRemaining?: number;
}
export class UpdateUserDto {
@ApiPropertyOptional({
description: 'User subscription plan',
enum: Plan
})
@IsOptional()
@IsEnum(Plan)
plan?: Plan;
@ApiPropertyOptional({
description: 'Remaining quota for current period',
minimum: 0
})
@IsOptional()
@IsInt()
@Min(0)
quotaRemaining?: number;
@ApiPropertyOptional({
description: 'Whether the user account is active'
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UserResponseDto {
@ApiProperty({
description: 'Unique user identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
id: string;
@ApiPropertyOptional({
description: 'Google OAuth UID',
example: 'google_123456789'
})
@IsOptional()
@IsString()
googleUid?: string;
@ApiProperty({
description: 'User email address',
example: 'user@example.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'User subscription plan',
enum: Plan
})
@IsEnum(Plan)
plan: Plan;
@ApiProperty({
description: 'Remaining quota for current period',
example: 50
})
@IsInt()
@Min(0)
quotaRemaining: number;
@ApiProperty({
description: 'Date when quota resets'
})
@IsDate()
quotaResetDate: Date;
@ApiProperty({
description: 'Whether the user account is active'
})
@IsBoolean()
isActive: boolean;
@ApiProperty({
description: 'User creation timestamp'
})
@IsDate()
createdAt: Date;
@ApiProperty({
description: 'User last update timestamp'
})
@IsDate()
updatedAt: Date;
}
export class UserStatsDto {
@ApiProperty({
description: 'Total number of batches processed'
})
@IsInt()
@Min(0)
totalBatches: number;
@ApiProperty({
description: 'Total number of images processed'
})
@IsInt()
@Min(0)
totalImages: number;
@ApiProperty({
description: 'Current quota usage this period'
})
@IsInt()
@Min(0)
quotaUsed: number;
@ApiProperty({
description: 'Total quota for current plan'
})
@IsInt()
@Min(0)
totalQuota: number;
@ApiProperty({
description: 'Percentage of quota used'
})
@IsInt()
@Min(0)
quotaUsagePercentage: number;
}
// Helper function to get quota limits by plan
export function getQuotaLimitForPlan(plan: Plan): number {
switch (plan) {
case Plan.BASIC:
return 50;
case Plan.PRO:
return 500;
case Plan.MAX:
return 1000;
default:
return 50;
}
}
// Helper function to calculate quota reset date (monthly)
export function calculateQuotaResetDate(): Date {
const now = new Date();
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return nextMonth;
}