Merge branch 'feature/auth-google-oauth' into feature/production-complete

This commit is contained in:
DustyWalker 2025-08-05 17:46:58 +02:00
commit 68ec648c2c
20 changed files with 1833 additions and 41 deletions

125
CLAUDE.md Normal file
View file

@ -0,0 +1,125 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is the **AI Bulk Image Renamer** SaaS - a web application that allows users to rename multiple images in batches using AI-generated keywords and computer vision tags. The goal is to create SEO-friendly, filesystem-safe, and semantically descriptive filenames.
### MVP Requirements (From README.md:22-31)
- Single landing page with upload functionality
- User-supplied keywords for filename generation
- "Enhance with AI" button to expand keyword lists
- Image thumbnails display after upload
- Generated filenames shown beneath corresponding images
- Download as ZIP functionality for renamed images
## Architecture & Tech Stack
Based on the development plans, the intended architecture is:
### Stack (From plan-for-devs.md:6-13)
- **Monorepo**: pnpm workspaces
- **Language**: TypeScript everywhere (Next.js + tRPC or Nest.js API / BullMQ worker)
- **Database**: PostgreSQL 15 via Prisma
- **Queues**: Redis + BullMQ for background jobs
- **Containers**: Docker dev-container with Docker Compose
### Core Components
- **Frontend**: Next.js with drag-and-drop upload, progress tracking, review table
- **Backend API**: Authentication (Google OAuth), quota management, batch processing
- **Worker Service**: Image processing, virus scanning (ClamAV), AI vision analysis
- **Object Storage**: MinIO (S3-compatible) for image storage
### Database Schema (From plan-for-devs.md:39-42)
- `users` table with Google OAuth integration
- `batches` table for upload sessions
- `images` table for individual image processing
## Key Features & Requirements
### Quota System
- **Basic Plan**: 50 images/month (free)
- **Pro Plan**: 500 images/month
- **Max Plan**: 1,000 images/month
### Processing Pipeline
1. File upload with SHA-256 deduplication
2. Virus scanning with ClamAV
3. Google Cloud Vision API for image labeling (>0.40 confidence)
4. Filename generation algorithm
5. Real-time progress via WebSockets
6. Review table with inline editing
7. ZIP download with preserved EXIF data
## Development Workflow
### Branch Strategy (From plan-for-devs.md:18-26)
- **Main branch**: `main` (always deployable)
- **Feature branches**: `feature/*`, `bugfix/*`
- **Release branches**: `release/*` (optional)
- **Hotfix branches**: `hotfix/*`
### Team Structure (From plan-for-devs.md:17-25)
- **Dev A**: Backend/API (Auth, quota, DB migrations)
- **Dev B**: Worker & Vision (Queue, ClamAV, Vision processing)
- **Dev C**: Frontend (Dashboard, drag-and-drop, review table)
## Security & Compliance
### Requirements
- Google OAuth 2.0 with email scope only
- GPG/SSH signed commits required
- Branch protection on `main` with 2 reviewer approvals
- ClamAV virus scanning before processing
- Rate limiting on all API endpoints
- Secrets stored in Forgejo encrypted vault
### Data Handling
- Only hashed emails stored
- EXIF data preservation
- Secure object storage paths: `/{batchUuid}/{filename}`
## Development Environment
### Local Setup (From plan-for-devs.md:32-37)
```yaml
# docker-compose.dev.yml services needed:
- postgres
- redis
- maildev (for testing)
- minio (S3-compatible object store)
- clamav
```
### CI/CD Pipeline (From plan-for-devs.md:46-52)
- ESLint + Prettier + Vitest/Jest + Cypress
- Forgejo Actions with Docker runner
- Multi-stage Dockerfile (≤300MB final image)
- Status checks required for merge
## API Endpoints
### Core Endpoints (From plan-for-devs.md:49-52)
- `/api/batch` - Create new batch, accept multipart form
- `/api/batch/{id}/status` - Get processing status
- `/api/batch/{id}/zip` - Download renamed images
- WebSocket connection for real-time progress updates
## Performance & Monitoring
### Targets
- Lighthouse scores ≥90
- OpenTelemetry trace IDs
- Prometheus histograms
- Kubernetes liveness & readiness probes
## Important Files
- `README.md` - Full product specification
- `plan-for-devs.md` - Development workflow and team structure
- `plan.md` - Detailed 7-week development backlog
## No Build Commands Available
This repository currently contains only planning documents. No package.json, requirements.txt, or other dependency files exist yet. The actual codebase implementation will follow the technical specifications outlined in the planning documents.

View file

@ -0,0 +1,15 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "c:\\Users\\hghgh\\Documents\\projects-roo\\Seoimagenew\\SEO_iamge_renamer_starting_point\\logs\\.d56b634021edaec5c4c8bdac5c3c6d66231098da-audit.json",
"files": [
{
"date": 1754404745817,
"name": "c:\\Users\\hghgh\\Documents\\projects-roo\\Seoimagenew\\SEO_iamge_renamer_starting_point\\logs\\mcp-puppeteer-2025-08-05.log",
"hash": "dfcf08cf4631acbd134e99ec9e47dd4da6ebadce62e84650213a9484f447c754"
}
],
"hashType": "sha256"
}

View file

@ -0,0 +1,2 @@
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-08-05 16:39:05.872"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-08-05 16:39:05.873"}

View file

@ -1,51 +1,43 @@
# Database
DATABASE_URL="postgresql://username:password@localhost:5432/seo_image_renamer?schema=public"
# Application
NODE_ENV="development"
PORT=3001
API_PREFIX="api/v1"
# Database Configuration
DATABASE_URL="postgresql://username:password@localhost:5432/seo_image_renamer"
# JWT Configuration
JWT_SECRET="your-super-secret-jwt-key-here"
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
JWT_EXPIRES_IN="7d"
# Google OAuth
GOOGLE_CLIENT_ID="your-google-client-id"
# Google OAuth Configuration
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GOOGLE_REDIRECT_URI="http://localhost:3001/api/v1/auth/google/callback"
GOOGLE_CALLBACK_URL="http://localhost:3001/api/auth/google/callback"
# Stripe Configuration
STRIPE_SECRET_KEY="sk_test_your_stripe_secret_key"
STRIPE_PUBLISHABLE_KEY="pk_test_your_stripe_publishable_key"
STRIPE_WEBHOOK_SECRET="whsec_your_stripe_webhook_secret"
# AWS S3 Configuration
AWS_ACCESS_KEY_ID="your-aws-access-key"
AWS_SECRET_ACCESS_KEY="your-aws-secret-key"
AWS_REGION="us-east-1"
AWS_S3_BUCKET="seo-image-renamer-uploads"
# OpenAI Configuration
OPENAI_API_KEY="sk-your-openai-api-key"
OPENAI_MODEL="gpt-4-vision-preview"
# Frontend URL (for CORS)
# Application Configuration
NODE_ENV="development"
PORT=3001
FRONTEND_URL="http://localhost:3000"
# Redis (for caching and queues)
REDIS_URL="redis://localhost:6379"
# CORS Configuration
CORS_ORIGIN="http://localhost:3000"
# Email Configuration (optional)
SMTP_HOST="smtp.gmail.com"
SMTP_PORT=587
SMTP_USER="your-email@gmail.com"
SMTP_PASS="your-email-password"
FROM_EMAIL="noreply@seo-image-renamer.com"
# Session Configuration
SESSION_SECRET="your-session-secret-change-this-in-production"
# Monitoring (optional)
SENTRY_DSN="https://your-sentry-dsn"
# Stripe Configuration (for payments)
STRIPE_SECRET_KEY="sk_test_your_stripe_secret_key"
STRIPE_WEBHOOK_SECRET="whsec_your_webhook_secret"
# Rate Limiting
RATE_LIMIT_TTL=60
RATE_LIMIT_LIMIT=10
# AWS S3 Configuration (for image storage)
AWS_REGION="us-east-1"
AWS_ACCESS_KEY_ID="your-aws-access-key"
AWS_SECRET_ACCESS_KEY="your-aws-secret-key"
S3_BUCKET_NAME="seo-image-renamer-uploads"
# OpenAI Configuration (for AI image analysis)
OPENAI_API_KEY="sk-your-openai-api-key"
# Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=10
# Security Configuration
BCRYPT_SALT_ROUNDS=12
COOKIE_SECRET="your-cookie-secret-change-this-in-production"

View file

@ -45,7 +45,8 @@
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"uuid": "^9.0.1",
"stripe": "^14.10.0"
"stripe": "^14.10.0",
"cookie-parser": "^1.4.6"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
@ -59,6 +60,7 @@
"@types/passport-google-oauth20": "^2.0.14",
"@types/bcrypt": "^5.0.2",
"@types/uuid": "^9.0.7",
"@types/cookie-parser": "^1.4.6",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",

View file

@ -0,0 +1,42 @@
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { JwtAuthGuard } from './auth/auth.guard';
import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
import { SecurityMiddleware } from './common/middleware/security.middleware';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
cache: true,
}),
DatabaseModule,
AuthModule,
UsersModule,
],
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Apply security middleware to all routes
consumer
.apply(SecurityMiddleware)
.forRoutes('*');
// Apply rate limiting to authentication routes
consumer
.apply(RateLimitMiddleware)
.forRoutes('auth/*');
}
}

View file

@ -0,0 +1,235 @@
import {
Controller,
Get,
Post,
UseGuards,
Req,
Res,
HttpStatus,
HttpException,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiExcludeEndpoint,
} from '@nestjs/swagger';
import { User } from '@prisma/client';
import { AuthService } from './auth.service';
import { GoogleAuthGuard, JwtAuthGuard, Public } from './auth.guard';
import {
LoginResponseDto,
LogoutResponseDto,
AuthProfileDto
} from './dto/auth.dto';
export interface AuthenticatedRequest extends Request {
user: User;
}
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor(private readonly authService: AuthService) {}
@Get('google')
@Public()
@UseGuards(GoogleAuthGuard)
@ApiOperation({
summary: 'Initiate Google OAuth authentication',
description: 'Redirects user to Google OAuth consent screen'
})
@ApiResponse({
status: 302,
description: 'Redirect to Google OAuth'
})
@ApiExcludeEndpoint() // Don't show in Swagger UI as it's a redirect
async googleAuth() {
// Guard handles the redirect to Google
// This method exists for the decorator
}
@Get('google/callback')
@Public()
@UseGuards(GoogleAuthGuard)
@ApiOperation({
summary: 'Google OAuth callback',
description: 'Handles the callback from Google OAuth and creates/logs in user'
})
@ApiResponse({
status: 200,
description: 'Authentication successful',
type: LoginResponseDto
})
@ApiResponse({
status: 401,
description: 'Authentication failed'
})
@ApiExcludeEndpoint() // Don't show in Swagger UI as it's a callback
async googleCallback(
@Req() req: AuthenticatedRequest,
@Res() res: Response,
) {
try {
if (!req.user) {
throw new HttpException('Authentication failed', HttpStatus.UNAUTHORIZED);
}
// Generate JWT tokens for the authenticated user
const tokenData = await this.authService.generateTokens(req.user);
// Get frontend URL from config
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
// Set secure HTTP-only cookie with the JWT token
res.cookie('access_token', tokenData.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: tokenData.expiresIn * 1000, // Convert to milliseconds
path: '/',
});
// Redirect to frontend with success indication
const redirectUrl = `${frontendUrl}/auth/success?user=${encodeURIComponent(
JSON.stringify({
id: tokenData.user.id,
email: tokenData.user.email,
plan: tokenData.user.plan,
quotaRemaining: tokenData.user.quotaRemaining,
})
)}`;
this.logger.log(`User ${req.user.email} authenticated successfully`);
return res.redirect(redirectUrl);
} catch (error) {
this.logger.error('OAuth callback error:', error);
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
return res.redirect(`${frontendUrl}/auth/error?message=${encodeURIComponent('Authentication failed')}`);
}
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Logout user',
description: 'Invalidates the user session and clears authentication cookies'
})
@ApiResponse({
status: 200,
description: 'Successfully logged out',
type: LogoutResponseDto
})
@ApiResponse({
status: 401,
description: 'Unauthorized'
})
async logout(
@Req() req: AuthenticatedRequest,
@Res() res: Response,
): Promise<Response> {
try {
const result = await this.authService.logout(req.user.id);
// Clear the authentication cookie
res.clearCookie('access_token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
this.logger.log(`User ${req.user.email} logged out successfully`);
return res.status(HttpStatus.OK).json(result);
} catch (error) {
this.logger.error('Logout error:', error);
throw new HttpException('Logout failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Get('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get current user profile',
description: 'Returns the authenticated user\'s profile information'
})
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
type: AuthProfileDto
})
@ApiResponse({
status: 401,
description: 'Unauthorized'
})
async getProfile(@Req() req: AuthenticatedRequest): Promise<AuthProfileDto> {
try {
const user = await this.authService.getProfile(req.user.id);
return {
id: user.id,
email: user.email,
plan: user.plan,
quotaRemaining: user.quotaRemaining,
quotaResetDate: user.quotaResetDate,
isActive: user.isActive,
createdAt: user.createdAt,
};
} catch (error) {
this.logger.error('Get profile error:', error);
throw new HttpException('Failed to retrieve profile', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Get('status')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Check authentication status',
description: 'Verifies if the current JWT token is valid'
})
@ApiResponse({
status: 200,
description: 'Token is valid',
schema: {
type: 'object',
properties: {
authenticated: { type: 'boolean', example: true },
user: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
plan: { type: 'string' },
}
}
}
}
})
@ApiResponse({
status: 401,
description: 'Token is invalid or expired'
})
async checkStatus(@Req() req: AuthenticatedRequest) {
return {
authenticated: true,
user: {
id: req.user.id,
email: req.user.email,
plan: req.user.plan,
quotaRemaining: req.user.quotaRemaining,
},
};
}
}

View file

@ -0,0 +1,84 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
// Decorator to mark routes as public (skip authentication)
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// Decorator to mark routes as optional authentication
export const IS_OPTIONAL_AUTH_KEY = 'isOptionalAuth';
export const OptionalAuth = () => SetMetadata(IS_OPTIONAL_AUTH_KEY, true);
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
constructor(private reflector: Reflector) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
// Check if route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
// Check if route has optional authentication
const isOptionalAuth = this.reflector.getAllAndOverride<boolean>(
IS_OPTIONAL_AUTH_KEY,
[context.getHandler(), context.getClass()],
);
if (isOptionalAuth) {
// Try to authenticate but don't fail if no token
try {
return super.canActivate(context);
} catch {
return true; // Allow request to proceed without authentication
}
}
// Default behavior: require authentication
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any, context: ExecutionContext) {
// Check if route has optional authentication
const isOptionalAuth = this.reflector.getAllAndOverride<boolean>(
IS_OPTIONAL_AUTH_KEY,
[context.getHandler(), context.getClass()],
);
if (err || !user) {
if (isOptionalAuth) {
return null; // No user, but that's okay for optional auth
}
throw err || new UnauthorizedException('Authentication required');
}
return user;
}
}
@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {
constructor() {
super({
accessType: 'offline',
prompt: 'consent',
});
}
}

View file

@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy';
import { DatabaseModule } from '../database/database.module';
@Module({
imports: [
DatabaseModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN', '7d'),
issuer: 'seo-image-renamer',
audience: 'seo-image-renamer-users',
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, GoogleStrategy, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View file

@ -0,0 +1,187 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { User, Plan } from '@prisma/client';
import { createHash } from 'crypto';
import { UserRepository } from '../database/repositories/user.repository';
import { LoginResponseDto, AuthUserDto } from './dto/auth.dto';
import { calculateQuotaResetDate, getQuotaLimitForPlan } from '../users/users.entity';
export interface GoogleUserData {
googleUid: string;
email: string;
displayName?: string;
}
export interface JwtPayload {
sub: string; // User ID
email: string;
iat?: number;
exp?: number;
iss?: string;
aud?: string;
}
@Injectable()
export class AuthService {
constructor(
private readonly userRepository: UserRepository,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
/**
* Validate and find/create user from Google OAuth data
*/
async validateGoogleUser(googleUserData: GoogleUserData): Promise<User> {
const { googleUid, email, displayName } = googleUserData;
// First, try to find user by Google UID
let user = await this.userRepository.findByGoogleUid(googleUid);
if (user) {
// User exists, update last login and return
return await this.userRepository.updateLastLogin(user.id);
}
// Check if user exists with this email but no Google UID (existing account)
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser && !existingUser.googleUid) {
// Link Google account to existing user
return await this.userRepository.linkGoogleAccount(existingUser.id, googleUid);
}
if (existingUser && existingUser.googleUid && existingUser.googleUid !== googleUid) {
throw new ConflictException('Email already associated with different Google account');
}
// Create new user account
return await this.createUserFromGoogle(googleUserData);
}
/**
* Create new user from Google OAuth data
*/
private async createUserFromGoogle(googleUserData: GoogleUserData): Promise<User> {
const { googleUid, email, displayName } = googleUserData;
// Hash the email for privacy (SHA-256)
const emailHash = this.hashEmail(email);
// Create user with Basic plan and 50 quota as per requirements
const userData = {
googleUid,
email,
emailHash,
plan: Plan.BASIC,
quotaRemaining: getQuotaLimitForPlan(Plan.BASIC),
quotaResetDate: calculateQuotaResetDate(),
isActive: true,
};
return await this.userRepository.createWithOAuth(userData);
}
/**
* Validate user by ID (for JWT strategy)
*/
async validateUserById(userId: string): Promise<User | null> {
return await this.userRepository.findById(userId);
}
/**
* Generate JWT token for user
*/
async generateTokens(user: User): Promise<LoginResponseDto> {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
};
const accessToken = await this.jwtService.signAsync(payload);
const expiresIn = this.getTokenExpirationSeconds();
const authUser: AuthUserDto = {
id: user.id,
email: user.email,
displayName: user.email.split('@')[0], // Use email prefix as display name
plan: user.plan,
quotaRemaining: user.quotaRemaining,
};
return {
accessToken,
tokenType: 'Bearer',
expiresIn,
user: authUser,
};
}
/**
* Get user profile information
*/
async getProfile(userId: string): Promise<User> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
/**
* Hash email using SHA-256 for privacy
*/
private hashEmail(email: string): string {
return createHash('sha256').update(email.toLowerCase().trim()).digest('hex');
}
/**
* Get token expiration time in seconds
*/
private getTokenExpirationSeconds(): number {
const expiresIn = this.configService.get<string>('JWT_EXPIRES_IN', '7d');
// Convert duration string to seconds
if (expiresIn.endsWith('d')) {
return parseInt(expiresIn.replace('d', '')) * 24 * 60 * 60;
} else if (expiresIn.endsWith('h')) {
return parseInt(expiresIn.replace('h', '')) * 60 * 60;
} else if (expiresIn.endsWith('m')) {
return parseInt(expiresIn.replace('m', '')) * 60;
} else if (expiresIn.endsWith('s')) {
return parseInt(expiresIn.replace('s', ''));
}
// Default to seconds if no unit specified
return parseInt(expiresIn) || 604800; // 7 days default
}
/**
* Validate JWT token and return payload
*/
async validateToken(token: string): Promise<JwtPayload | null> {
try {
return await this.jwtService.verifyAsync(token);
} catch {
return null;
}
}
/**
* Invalidate user session (for logout)
* Note: With stateless JWT, we rely on token expiration
* In production, consider maintaining a blacklist
*/
async logout(userId: string): Promise<{ message: string }> {
// Update user's last activity
await this.userRepository.updateLastActivity(userId);
return { message: 'Successfully logged out' };
}
}

View file

@ -0,0 +1,137 @@
import { IsString, IsEmail, IsOptional, IsUUID } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class GoogleOAuthCallbackDto {
@ApiProperty({
description: 'Authorization code from Google OAuth',
example: 'auth_code_from_google'
})
@IsString()
code: string;
@ApiPropertyOptional({
description: 'OAuth state parameter for CSRF protection',
example: 'random_state_string'
})
@IsOptional()
@IsString()
state?: string;
}
export class LoginResponseDto {
@ApiProperty({
description: 'JWT access token',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
@IsString()
accessToken: string;
@ApiProperty({
description: 'Token type',
example: 'Bearer'
})
@IsString()
tokenType: string;
@ApiProperty({
description: 'Token expiration time in seconds',
example: 604800
})
expiresIn: number;
@ApiProperty({
description: 'User information',
type: () => AuthUserDto
})
user: AuthUserDto;
}
export class AuthUserDto {
@ApiProperty({
description: 'User unique identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
id: string;
@ApiProperty({
description: 'User email address',
example: 'user@example.com'
})
@IsEmail()
email: string;
@ApiPropertyOptional({
description: 'User display name from Google',
example: 'John Doe'
})
@IsOptional()
@IsString()
displayName?: string;
@ApiProperty({
description: 'User subscription plan',
example: 'BASIC'
})
@IsString()
plan: string;
@ApiProperty({
description: 'Remaining quota for current period',
example: 50
})
quotaRemaining: number;
}
export class LogoutResponseDto {
@ApiProperty({
description: 'Logout success message',
example: 'Successfully logged out'
})
@IsString()
message: string;
}
export class AuthProfileDto {
@ApiProperty({
description: 'User unique identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@IsUUID()
id: string;
@ApiProperty({
description: 'User email address',
example: 'user@example.com'
})
@IsEmail()
email: string;
@ApiProperty({
description: 'User subscription plan',
example: 'BASIC'
})
@IsString()
plan: string;
@ApiProperty({
description: 'Remaining quota for current period',
example: 50
})
quotaRemaining: number;
@ApiProperty({
description: 'Date when quota resets'
})
quotaResetDate: Date;
@ApiProperty({
description: 'Whether the user account is active'
})
isActive: boolean;
@ApiProperty({
description: 'User creation timestamp'
})
createdAt: Date;
}

View file

@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { AuthService } from './auth.service';
export interface GoogleProfile {
id: string;
displayName: string;
name: {
familyName: string;
givenName: string;
};
emails: Array<{
value: string;
verified: boolean;
}>;
photos: Array<{
value: string;
}>;
provider: string;
_raw: string;
_json: any;
}
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
super({
clientID: configService.get<string>('GOOGLE_CLIENT_ID'),
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET'),
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'], // Only request email and profile scopes as per requirements
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: GoogleProfile,
done: VerifyCallback,
): Promise<void> {
try {
// Extract user information from Google profile
const { id, displayName, emails } = profile;
if (!emails || emails.length === 0) {
return done(new Error('No email found in Google profile'), null);
}
const email = emails[0].value;
// Find or create user through auth service
const user = await this.authService.validateGoogleUser({
googleUid: id,
email,
displayName,
});
return done(null, user);
} catch (error) {
return done(error, null);
}
}
}

View file

@ -0,0 +1,56 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { AuthService } from './auth.service';
export interface JwtPayload {
sub: string; // User ID
email: string;
iat: number; // Issued at
exp: number; // Expires at
iss: string; // Issuer
aud: string; // Audience
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
issuer: 'seo-image-renamer',
audience: 'seo-image-renamer-users',
});
}
async validate(payload: JwtPayload) {
try {
// Verify the user still exists and is active
const user = await this.authService.validateUserById(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
}
if (!user.isActive) {
throw new UnauthorizedException('User account is inactive');
}
// Return user object that will be attached to request
return {
id: user.id,
email: user.email,
plan: user.plan,
quotaRemaining: user.quotaRemaining,
isActive: user.isActive,
};
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
}

View file

@ -0,0 +1,89 @@
import { Injectable, NestMiddleware, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
interface RateLimitStore {
[key: string]: {
count: number;
resetTime: number;
};
}
@Injectable()
export class RateLimitMiddleware implements NestMiddleware {
private store: RateLimitStore = {};
private readonly windowMs: number = 60 * 1000; // 1 minute
private readonly maxRequests: number = 10; // 10 requests per minute for auth endpoints
use(req: Request, res: Response, next: NextFunction): void {
const clientId = this.getClientId(req);
const now = Date.now();
// Clean up expired entries
this.cleanup(now);
// Get or create rate limit entry for this client
if (!this.store[clientId]) {
this.store[clientId] = {
count: 0,
resetTime: now + this.windowMs,
};
}
const clientData = this.store[clientId];
// Check if window has expired
if (now > clientData.resetTime) {
clientData.count = 0;
clientData.resetTime = now + this.windowMs;
}
// Check rate limit
if (clientData.count >= this.maxRequests) {
const remainingTime = Math.ceil((clientData.resetTime - now) / 1000);
res.setHeader('X-RateLimit-Limit', this.maxRequests);
res.setHeader('X-RateLimit-Remaining', 0);
res.setHeader('X-RateLimit-Reset', Math.ceil(clientData.resetTime / 1000));
res.setHeader('Retry-After', remainingTime);
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: `Too many requests. Try again in ${remainingTime} seconds.`,
error: 'Too Many Requests',
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
// Increment counter
clientData.count++;
// Set response headers
res.setHeader('X-RateLimit-Limit', this.maxRequests);
res.setHeader('X-RateLimit-Remaining', this.maxRequests - clientData.count);
res.setHeader('X-RateLimit-Reset', Math.ceil(clientData.resetTime / 1000));
next();
}
private getClientId(req: Request): string {
// Use forwarded IP if behind proxy, otherwise use connection IP
const forwarded = req.headers['x-forwarded-for'] as string;
const ip = forwarded ? forwarded.split(',')[0].trim() : req.connection.remoteAddress;
// Include user agent for additional uniqueness
const userAgent = req.headers['user-agent'] || 'unknown';
return `${ip}:${userAgent}`;
}
private cleanup(now: number): void {
// Remove expired entries to prevent memory leak
for (const [clientId, data] of Object.entries(this.store)) {
if (now > data.resetTime + this.windowMs) {
delete this.store[clientId];
}
}
}
}

View file

@ -0,0 +1,102 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class SecurityMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
// CSRF Protection for state-changing requests
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
this.applyCsrfProtection(req, res);
}
// Security Headers
this.setSecurityHeaders(res);
next();
}
private applyCsrfProtection(req: Request, res: Response): void {
// Skip CSRF for OAuth callbacks and API endpoints with JWT
const skipPaths = [
'/auth/google/callback',
'/auth/google',
];
if (skipPaths.some(path => req.path.includes(path))) {
return;
}
// For JWT-protected endpoints, the JWT itself provides CSRF protection
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return;
}
// For cookie-based requests, check for CSRF token
const csrfToken = req.headers['x-csrf-token'] as string;
const cookieToken = req.cookies?.['csrf-token'];
if (!csrfToken || csrfToken !== cookieToken) {
// Set CSRF token if not present
if (!cookieToken) {
const token = this.generateCsrfToken();
res.cookie('csrf-token', token, {
httpOnly: false, // Allow JS access for CSRF token
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 1000, // 1 hour
});
}
}
}
private setSecurityHeaders(res: Response): void {
// Content Security Policy
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://accounts.google.com; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://accounts.google.com; " +
"frame-src https://accounts.google.com; " +
"object-src 'none'; " +
"base-uri 'self';"
);
// X-Content-Type-Options
res.setHeader('X-Content-Type-Options', 'nosniff');
// X-Frame-Options
res.setHeader('X-Frame-Options', 'DENY');
// X-XSS-Protection
res.setHeader('X-XSS-Protection', '1; mode=block');
// Referrer Policy
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions Policy
res.setHeader(
'Permissions-Policy',
'geolocation=(), microphone=(), camera=(), fullscreen=(self)'
);
// Strict Transport Security (HTTPS only)
if (process.env.NODE_ENV === 'production') {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
}
}
private generateCsrfToken(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
}

View file

@ -299,6 +299,73 @@ export class UserRepository {
}
}
/**
* Link Google account to existing user
*/
async linkGoogleAccount(userId: string, googleUid: string): Promise<User> {
try {
return await this.prisma.user.update({
where: { id: userId },
data: { googleUid },
});
} catch (error) {
this.logger.error(`Failed to link Google account for user ${userId}:`, error);
throw error;
}
}
/**
* Update user's last login timestamp
*/
async updateLastLogin(userId: string): Promise<User> {
try {
return await this.prisma.user.update({
where: { id: userId },
data: { updatedAt: new Date() },
});
} catch (error) {
this.logger.error(`Failed to update last login for user ${userId}:`, error);
throw error;
}
}
/**
* Update user's last activity timestamp
*/
async updateLastActivity(userId: string): Promise<User> {
try {
return await this.prisma.user.update({
where: { id: userId },
data: { updatedAt: new Date() },
});
} catch (error) {
this.logger.error(`Failed to update last activity for user ${userId}:`, error);
throw error;
}
}
/**
* Create user with OAuth data
*/
async createWithOAuth(data: {
googleUid: string;
email: string;
emailHash: string;
plan: Plan;
quotaRemaining: number;
quotaResetDate: Date;
isActive: boolean;
}): Promise<User> {
try {
return await this.prisma.user.create({
data,
});
} catch (error) {
this.logger.error('Failed to create user with OAuth data:', error);
throw error;
}
}
/**
* Helper: Calculate next quota reset date (first day of next month)
*/

105
packages/api/src/main.ts Normal file
View file

@ -0,0 +1,105 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import helmet from 'helmet';
import * as compression from 'compression';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Global prefix for API routes
app.setGlobalPrefix('api');
// Enable CORS
app.enableCors({
origin: configService.get<string>('CORS_ORIGIN', 'http://localhost:3000'),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'X-Requested-With',
'X-CSRF-Token',
'Accept',
],
});
// Security middleware
app.use(helmet({
contentSecurityPolicy: false, // We handle CSP in our custom middleware
crossOriginEmbedderPolicy: false, // Allow embedding for OAuth
}));
// Compression middleware
app.use(compression());
// Cookie parser
app.use(cookieParser(configService.get<string>('COOKIE_SECRET')));
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Throw error for unknown properties
transform: true, // Transform payloads to DTO instances
disableErrorMessages: process.env.NODE_ENV === 'production',
}),
);
// Swagger documentation (development only)
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('SEO Image Renamer API')
.setDescription('AI-powered bulk image renaming SaaS API')
.setVersion('1.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'JWT',
description: 'Enter JWT token',
in: 'header',
},
'JWT-auth',
)
.addTag('Authentication', 'Google OAuth and JWT authentication')
.addTag('Users', 'User management and profile operations')
.addTag('Batches', 'Image batch processing')
.addTag('Images', 'Individual image operations')
.addTag('Payments', 'Stripe payment processing')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document, {
customSiteTitle: 'SEO Image Renamer API Documentation',
customfavIcon: '/favicon.ico',
customCss: '.swagger-ui .topbar { display: none }',
});
logger.log('Swagger documentation available at /api/docs');
}
// Start server
const port = configService.get<number>('PORT', 3001);
await app.listen(port);
logger.log(`🚀 SEO Image Renamer API running on port ${port}`);
logger.log(`📚 Environment: ${process.env.NODE_ENV || 'development'}`);
if (process.env.NODE_ENV !== 'production') {
logger.log(`📖 API Documentation: http://localhost:${port}/api/docs`);
}
}
bootstrap().catch((error) => {
Logger.error('Failed to start application', error);
process.exit(1);
});

View file

@ -0,0 +1,230 @@
import {
Controller,
Get,
Put,
Delete,
Body,
Param,
UseGuards,
Req,
HttpStatus,
Logger,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/auth.guard';
import {
UpdateUserDto,
UserResponseDto,
UserStatsDto
} from './users.entity';
export interface AuthenticatedRequest {
user: {
id: string;
email: string;
plan: string;
quotaRemaining: number;
isActive: boolean;
};
}
@ApiTags('Users')
@Controller('users')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class UsersController {
private readonly logger = new Logger(UsersController.name);
constructor(private readonly usersService: UsersService) {}
@Get('me')
@ApiOperation({
summary: 'Get current user profile',
description: 'Returns the authenticated user\'s profile information'
})
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
type: UserResponseDto
})
@ApiResponse({
status: 401,
description: 'Unauthorized'
})
@ApiResponse({
status: 404,
description: 'User not found'
})
async getProfile(@Req() req: AuthenticatedRequest): Promise<UserResponseDto> {
return await this.usersService.getProfile(req.user.id);
}
@Put('me')
@ApiOperation({
summary: 'Update current user profile',
description: 'Updates the authenticated user\'s profile information'
})
@ApiResponse({
status: 200,
description: 'User profile updated successfully',
type: UserResponseDto
})
@ApiResponse({
status: 400,
description: 'Invalid update data'
})
@ApiResponse({
status: 401,
description: 'Unauthorized'
})
@ApiResponse({
status: 404,
description: 'User not found'
})
async updateProfile(
@Req() req: AuthenticatedRequest,
@Body() updateData: UpdateUserDto,
): Promise<UserResponseDto> {
this.logger.log(`User ${req.user.email} updating profile`);
return await this.usersService.updateProfile(req.user.id, updateData);
}
@Get('me/stats')
@ApiOperation({
summary: 'Get current user statistics',
description: 'Returns usage statistics for the authenticated user'
})
@ApiResponse({
status: 200,
description: 'User statistics retrieved successfully',
type: UserStatsDto
})
@ApiResponse({
status: 401,
description: 'Unauthorized'
})
@ApiResponse({
status: 404,
description: 'User not found'
})
async getUserStats(@Req() req: AuthenticatedRequest): Promise<UserStatsDto> {
return await this.usersService.getUserStats(req.user.id);
}
@Delete('me')
@ApiOperation({
summary: 'Deactivate current user account',
description: 'Deactivates the authenticated user\'s account (soft delete)'
})
@ApiResponse({
status: 200,
description: 'User account deactivated successfully',
type: UserResponseDto
})
@ApiResponse({
status: 401,
description: 'Unauthorized'
})
@ApiResponse({
status: 404,
description: 'User not found'
})
async deactivateAccount(@Req() req: AuthenticatedRequest): Promise<UserResponseDto> {
this.logger.log(`User ${req.user.email} deactivating account`);
return await this.usersService.deactivateAccount(req.user.id);
}
@Put('me/reactivate')
@ApiOperation({
summary: 'Reactivate current user account',
description: 'Reactivates the authenticated user\'s account'
})
@ApiResponse({
status: 200,
description: 'User account reactivated successfully',
type: UserResponseDto
})
@ApiResponse({
status: 401,
description: 'Unauthorized'
})
@ApiResponse({
status: 404,
description: 'User not found'
})
async reactivateAccount(@Req() req: AuthenticatedRequest): Promise<UserResponseDto> {
this.logger.log(`User ${req.user.email} reactivating account`);
return await this.usersService.reactivateAccount(req.user.id);
}
@Get(':id')
@ApiOperation({
summary: 'Get user by ID',
description: 'Returns user information by ID (admin/internal use)'
})
@ApiParam({
name: 'id',
description: 'User unique identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
@ApiResponse({
status: 200,
description: 'User retrieved successfully',
type: UserResponseDto
})
@ApiResponse({
status: 401,
description: 'Unauthorized'
})
@ApiResponse({
status: 404,
description: 'User not found'
})
async findOne(@Param('id') id: string): Promise<UserResponseDto> {
return await this.usersService.findOne(id);
}
@Get('me/quota/check')
@ApiOperation({
summary: 'Check user quota availability',
description: 'Checks if the user has sufficient quota for operations'
})
@ApiResponse({
status: 200,
description: 'Quota check completed',
schema: {
type: 'object',
properties: {
hasQuota: { type: 'boolean', example: true },
quotaRemaining: { type: 'number', example: 45 },
quotaUsed: { type: 'number', example: 5 },
totalQuota: { type: 'number', example: 50 },
plan: { type: 'string', example: 'BASIC' },
}
}
})
@ApiResponse({
status: 401,
description: 'Unauthorized'
})
async checkQuota(@Req() req: AuthenticatedRequest) {
const hasQuota = await this.usersService.hasQuota(req.user.id);
const stats = await this.usersService.getUserStats(req.user.id);
return {
hasQuota,
quotaRemaining: req.user.quotaRemaining,
quotaUsed: stats.quotaUsed,
totalQuota: stats.totalQuota,
plan: req.user.plan,
};
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { DatabaseModule } from '../database/database.module';
@Module({
imports: [DatabaseModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View file

@ -0,0 +1,209 @@
import {
Injectable,
NotFoundException,
ConflictException,
Logger
} from '@nestjs/common';
import { User, Plan } from '@prisma/client';
import { UserRepository } from '../database/repositories/user.repository';
import {
CreateUserDto,
UpdateUserDto,
UserResponseDto,
UserStatsDto,
getQuotaLimitForPlan
} from './users.entity';
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
constructor(private readonly userRepository: UserRepository) {}
/**
* Get user by ID
*/
async findOne(id: string): Promise<UserResponseDto> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return this.mapToResponseDto(user);
}
/**
* Get current user profile
*/
async getProfile(userId: string): Promise<UserResponseDto> {
return await this.findOne(userId);
}
/**
* Update user profile
*/
async updateProfile(userId: string, updateData: UpdateUserDto): Promise<UserResponseDto> {
try {
// Check if user exists
const existingUser = await this.userRepository.findById(userId);
if (!existingUser) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
// If plan is being updated, adjust quota accordingly
if (updateData.plan && updateData.plan !== existingUser.plan) {
const newQuota = getQuotaLimitForPlan(updateData.plan);
updateData.quotaRemaining = newQuota;
}
const updatedUser = await this.userRepository.update(userId, updateData);
return this.mapToResponseDto(updatedUser);
} catch (error) {
this.logger.error(`Failed to update user profile ${userId}:`, error);
if (error instanceof NotFoundException) {
throw error;
}
throw new ConflictException('Failed to update user profile');
}
}
/**
* Get user statistics
*/
async getUserStats(userId: string): Promise<UserStatsDto> {
try {
const user = await this.userRepository.findByIdWithRelations(userId);
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
const totalQuota = getQuotaLimitForPlan(user.plan);
const quotaUsed = totalQuota - user.quotaRemaining;
const quotaUsagePercentage = Math.round((quotaUsed / totalQuota) * 100);
return {
totalBatches: user._count.batches,
totalImages: this.calculateTotalImages(user.batches),
quotaUsed,
totalQuota,
quotaUsagePercentage,
};
} catch (error) {
this.logger.error(`Failed to get user stats for ${userId}:`, error);
if (error instanceof NotFoundException) {
throw error;
}
throw new ConflictException('Failed to retrieve user statistics');
}
}
/**
* Deactivate user account
*/
async deactivateAccount(userId: string): Promise<UserResponseDto> {
try {
const updatedUser = await this.userRepository.update(userId, {
isActive: false
});
return this.mapToResponseDto(updatedUser);
} catch (error) {
this.logger.error(`Failed to deactivate user ${userId}:`, error);
throw new ConflictException('Failed to deactivate user account');
}
}
/**
* Reactivate user account
*/
async reactivateAccount(userId: string): Promise<UserResponseDto> {
try {
const updatedUser = await this.userRepository.update(userId, {
isActive: true
});
return this.mapToResponseDto(updatedUser);
} catch (error) {
this.logger.error(`Failed to reactivate user ${userId}:`, error);
throw new ConflictException('Failed to reactivate user account');
}
}
/**
* Check if user has sufficient quota
*/
async hasQuota(userId: string, requiredQuota: number = 1): Promise<boolean> {
const user = await this.userRepository.findById(userId);
if (!user) {
return false;
}
return user.quotaRemaining >= requiredQuota && user.isActive;
}
/**
* Deduct quota from user
*/
async deductQuota(userId: string, amount: number = 1): Promise<User> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
if (user.quotaRemaining < amount) {
throw new ConflictException('Insufficient quota remaining');
}
return await this.userRepository.deductQuota(userId, amount);
}
/**
* Reset user quota (for monthly resets)
*/
async resetQuota(userId: string): Promise<UserResponseDto> {
try {
const updatedUser = await this.userRepository.resetQuota(userId);
return this.mapToResponseDto(updatedUser);
} catch (error) {
this.logger.error(`Failed to reset quota for user ${userId}:`, error);
throw new ConflictException('Failed to reset user quota');
}
}
/**
* Upgrade user plan
*/
async upgradePlan(userId: string, newPlan: Plan): Promise<UserResponseDto> {
try {
const updatedUser = await this.userRepository.upgradePlan(userId, newPlan);
this.logger.log(`User ${userId} upgraded to ${newPlan} plan`);
return this.mapToResponseDto(updatedUser);
} catch (error) {
this.logger.error(`Failed to upgrade plan for user ${userId}:`, error);
throw new ConflictException('Failed to upgrade user plan');
}
}
/**
* Map User entity to UserResponseDto
*/
private mapToResponseDto(user: User): UserResponseDto {
return {
id: user.id,
googleUid: user.googleUid,
email: user.email,
plan: user.plan,
quotaRemaining: user.quotaRemaining,
quotaResetDate: user.quotaResetDate,
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
/**
* Calculate total images processed across all batches
*/
private calculateTotalImages(batches: any[]): number {
return batches.reduce((total, batch) => total + (batch.processedImages || 0), 0);
}
}