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
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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue