diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index fd5cfa35..3ab6fa9c 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -19,6 +19,7 @@ import { AuditModule } from '@modules/audit/audit.module'; import { FeatureFlagsModule } from '@modules/feature-flags/feature-flags.module'; import { HealthModule } from '@modules/health/health.module'; import { SuperadminModule } from '@modules/superadmin/superadmin.module'; +import { AIModule } from '@modules/ai/ai.module'; @Module({ imports: [ @@ -58,6 +59,7 @@ import { SuperadminModule } from '@modules/superadmin/superadmin.module'; FeatureFlagsModule, HealthModule, SuperadminModule, + AIModule, ], }) export class AppModule {} diff --git a/apps/backend/src/config/env.config.ts b/apps/backend/src/config/env.config.ts index 5f56c3da..5e50f275 100644 --- a/apps/backend/src/config/env.config.ts +++ b/apps/backend/src/config/env.config.ts @@ -27,6 +27,13 @@ export const envConfig = () => ({ webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '', }, + + ai: { + openrouterApiKey: process.env.OPENROUTER_API_KEY || '', + defaultModel: process.env.AI_DEFAULT_MODEL || 'anthropic/claude-3-haiku', + fallbackModel: process.env.AI_FALLBACK_MODEL || 'openai/gpt-3.5-turbo', + timeoutMs: parseInt(process.env.AI_TIMEOUT_MS || '30000', 10), + }, }); export const validationSchema = Joi.object({ @@ -51,4 +58,10 @@ export const validationSchema = Joi.object({ STRIPE_SECRET_KEY: Joi.string().allow('').default(''), STRIPE_WEBHOOK_SECRET: Joi.string().allow('').default(''), STRIPE_PUBLISHABLE_KEY: Joi.string().allow('').default(''), + + // AI (optional - integration disabled if not set) + OPENROUTER_API_KEY: Joi.string().allow('').default(''), + AI_DEFAULT_MODEL: Joi.string().default('anthropic/claude-3-haiku'), + AI_FALLBACK_MODEL: Joi.string().default('openai/gpt-3.5-turbo'), + AI_TIMEOUT_MS: Joi.number().default(30000), }); diff --git a/apps/backend/src/modules/ai/ai.controller.ts b/apps/backend/src/modules/ai/ai.controller.ts new file mode 100644 index 00000000..afcf357a --- /dev/null +++ b/apps/backend/src/modules/ai/ai.controller.ts @@ -0,0 +1,120 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AIService } from './services'; +import { + ChatRequestDto, + ChatResponseDto, + UpdateAIConfigDto, + AIConfigResponseDto, + UsageStatsDto, + AIModelDto, +} from './dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@ApiTags('ai') +@Controller('ai') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class AIController { + constructor(private readonly aiService: AIService) {} + + // ==================== Chat ==================== + + @Post('chat') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Send chat completion request' }) + @ApiResponse({ status: 200, description: 'Chat response', type: ChatResponseDto }) + @ApiResponse({ status: 400, description: 'Bad request or AI disabled' }) + async chat( + @CurrentUser() user: RequestUser, + @Body() dto: ChatRequestDto, + ): Promise { + return this.aiService.chat(user.tenant_id, user.id, dto); + } + + // ==================== Models ==================== + + @Get('models') + @ApiOperation({ summary: 'List available AI models' }) + @ApiResponse({ status: 200, description: 'List of models', type: [AIModelDto] }) + async getModels(): Promise { + return this.aiService.getModels(); + } + + // ==================== Configuration ==================== + + @Get('config') + @ApiOperation({ summary: 'Get AI configuration for tenant' }) + @ApiResponse({ status: 200, description: 'AI configuration', type: AIConfigResponseDto }) + async getConfig(@CurrentUser() user: RequestUser): Promise { + const config = await this.aiService.getConfig(user.tenant_id); + return config as AIConfigResponseDto; + } + + @Patch('config') + @ApiOperation({ summary: 'Update AI configuration' }) + @ApiResponse({ status: 200, description: 'Updated configuration', type: AIConfigResponseDto }) + async updateConfig( + @CurrentUser() user: RequestUser, + @Body() dto: UpdateAIConfigDto, + ): Promise { + const config = await this.aiService.updateConfig(user.tenant_id, dto); + return config as AIConfigResponseDto; + } + + // ==================== Usage ==================== + + @Get('usage') + @ApiOperation({ summary: 'Get usage history' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getUsage( + @CurrentUser() user: RequestUser, + @Query('page') page = 1, + @Query('limit') limit = 20, + ) { + return this.aiService.getUsageHistory(user.tenant_id, page, limit); + } + + @Get('usage/current') + @ApiOperation({ summary: 'Get current month usage stats' }) + @ApiResponse({ status: 200, description: 'Usage statistics', type: UsageStatsDto }) + async getCurrentUsage(@CurrentUser() user: RequestUser): Promise { + return this.aiService.getCurrentMonthUsage(user.tenant_id); + } + + // ==================== Health ==================== + + @Get('health') + @ApiOperation({ summary: 'Check AI service health' }) + async health() { + return { + status: this.aiService.isServiceReady() ? 'ready' : 'not_configured', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/apps/backend/src/modules/ai/ai.module.ts b/apps/backend/src/modules/ai/ai.module.ts new file mode 100644 index 00000000..f1e2890b --- /dev/null +++ b/apps/backend/src/modules/ai/ai.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import { AIController } from './ai.controller'; +import { AIService } from './services'; +import { OpenRouterClient } from './clients'; +import { AIConfig, AIUsage } from './entities'; + +@Module({ + imports: [ + ConfigModule, + TypeOrmModule.forFeature([AIConfig, AIUsage]), + ], + controllers: [AIController], + providers: [AIService, OpenRouterClient], + exports: [AIService], +}) +export class AIModule {} diff --git a/apps/backend/src/modules/ai/clients/index.ts b/apps/backend/src/modules/ai/clients/index.ts new file mode 100644 index 00000000..422375d1 --- /dev/null +++ b/apps/backend/src/modules/ai/clients/index.ts @@ -0,0 +1 @@ +export { OpenRouterClient } from './openrouter.client'; diff --git a/apps/backend/src/modules/ai/clients/openrouter.client.ts b/apps/backend/src/modules/ai/clients/openrouter.client.ts new file mode 100644 index 00000000..f16cff0a --- /dev/null +++ b/apps/backend/src/modules/ai/clients/openrouter.client.ts @@ -0,0 +1,234 @@ +import { Injectable, Logger, OnModuleInit, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ChatRequestDto, ChatResponseDto, AIModelDto } from '../dto'; + +interface OpenRouterRequest { + model: string; + messages: { role: string; content: string }[]; + temperature?: number; + max_tokens?: number; + top_p?: number; + stream?: boolean; +} + +interface OpenRouterResponse { + id: string; + model: string; + choices: { + index: number; + message: { role: string; content: string }; + finish_reason: string; + }[]; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; + created: number; +} + +interface OpenRouterModel { + id: string; + name: string; + description?: string; + context_length: number; + pricing: { + prompt: string; + completion: string; + }; +} + +@Injectable() +export class OpenRouterClient implements OnModuleInit { + private readonly logger = new Logger(OpenRouterClient.name); + private apiKey: string; + private readonly baseUrl = 'https://openrouter.ai/api/v1'; + private readonly timeout: number; + private isConfigured = false; + + constructor(private readonly configService: ConfigService) { + this.timeout = this.configService.get('AI_TIMEOUT_MS', 30000); + } + + onModuleInit() { + this.apiKey = this.configService.get('OPENROUTER_API_KEY', ''); + if (!this.apiKey) { + this.logger.warn('OpenRouter API key not configured. AI features will be disabled.'); + return; + } + this.isConfigured = true; + this.logger.log('OpenRouter client initialized'); + } + + isReady(): boolean { + return this.isConfigured; + } + + private ensureConfigured(): void { + if (!this.isConfigured) { + throw new BadRequestException('AI service is not configured. Please set OPENROUTER_API_KEY.'); + } + } + + async chatCompletion( + dto: ChatRequestDto, + defaultModel: string, + defaultTemperature: number, + defaultMaxTokens: number, + ): Promise { + this.ensureConfigured(); + + const requestBody: OpenRouterRequest = { + model: dto.model || defaultModel, + messages: dto.messages, + temperature: dto.temperature ?? defaultTemperature, + max_tokens: dto.max_tokens ?? defaultMaxTokens, + top_p: dto.top_p ?? 1.0, + stream: false, // For now, no streaming + }; + + const startTime = Date.now(); + + try { + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + 'HTTP-Referer': this.configService.get('APP_URL', 'http://localhost:3001'), + 'X-Title': 'Template SaaS', + }, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + const errorBody = await response.text(); + this.logger.error(`OpenRouter API error: ${response.status} - ${errorBody}`); + throw new BadRequestException(`AI request failed: ${response.statusText}`); + } + + const data: OpenRouterResponse = await response.json(); + const latencyMs = Date.now() - startTime; + + this.logger.debug(`Chat completion completed in ${latencyMs}ms, tokens: ${data.usage?.total_tokens}`); + + return { + id: data.id, + model: data.model, + choices: data.choices.map((c) => ({ + index: c.index, + message: { + role: c.message.role as 'system' | 'user' | 'assistant', + content: c.message.content, + }, + finish_reason: c.finish_reason, + })), + usage: { + prompt_tokens: data.usage?.prompt_tokens || 0, + completion_tokens: data.usage?.completion_tokens || 0, + total_tokens: data.usage?.total_tokens || 0, + }, + created: data.created, + }; + } catch (error) { + if (error.name === 'AbortError') { + throw new BadRequestException('AI request timed out'); + } + throw error; + } + } + + async getModels(): Promise { + this.ensureConfigured(); + + try { + const response = await fetch(`${this.baseUrl}/models`, { + headers: { + Authorization: `Bearer ${this.apiKey}`, + }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + throw new BadRequestException('Failed to fetch models'); + } + + const data = await response.json(); + const models: OpenRouterModel[] = data.data || []; + + // Filter to popular models + const popularModels = [ + 'anthropic/claude-3-haiku', + 'anthropic/claude-3-sonnet', + 'anthropic/claude-3-opus', + 'openai/gpt-4-turbo', + 'openai/gpt-4', + 'openai/gpt-3.5-turbo', + 'google/gemini-pro', + 'meta-llama/llama-3-70b-instruct', + ]; + + return models + .filter((m) => popularModels.some((p) => m.id.includes(p.split('/')[1]))) + .slice(0, 20) + .map((m) => ({ + id: m.id, + name: m.name, + provider: m.id.split('/')[0], + context_length: m.context_length, + pricing: { + prompt: parseFloat(m.pricing.prompt) * 1000000, // Per million tokens + completion: parseFloat(m.pricing.completion) * 1000000, + }, + })); + } catch (error) { + this.logger.error('Failed to fetch models:', error); + // Return default models if API fails + return [ + { + id: 'anthropic/claude-3-haiku', + name: 'Claude 3 Haiku', + provider: 'anthropic', + context_length: 200000, + pricing: { prompt: 0.25, completion: 1.25 }, + }, + { + id: 'openai/gpt-3.5-turbo', + name: 'GPT-3.5 Turbo', + provider: 'openai', + context_length: 16385, + pricing: { prompt: 0.5, completion: 1.5 }, + }, + ]; + } + } + + // Calculate cost for a request + calculateCost( + model: string, + inputTokens: number, + outputTokens: number, + ): { input: number; output: number; total: number } { + // Approximate pricing per million tokens (in USD) + const pricing: Record = { + 'anthropic/claude-3-haiku': { input: 0.25, output: 1.25 }, + 'anthropic/claude-3-sonnet': { input: 3.0, output: 15.0 }, + 'anthropic/claude-3-opus': { input: 15.0, output: 75.0 }, + 'openai/gpt-4-turbo': { input: 10.0, output: 30.0 }, + 'openai/gpt-4': { input: 30.0, output: 60.0 }, + 'openai/gpt-3.5-turbo': { input: 0.5, output: 1.5 }, + default: { input: 1.0, output: 2.0 }, + }; + + const modelPricing = pricing[model] || pricing.default; + const inputCost = (inputTokens / 1_000_000) * modelPricing.input; + const outputCost = (outputTokens / 1_000_000) * modelPricing.output; + + return { + input: inputCost, + output: outputCost, + total: inputCost + outputCost, + }; + } +} diff --git a/apps/backend/src/modules/ai/dto/chat.dto.ts b/apps/backend/src/modules/ai/dto/chat.dto.ts new file mode 100644 index 00000000..bcb400fa --- /dev/null +++ b/apps/backend/src/modules/ai/dto/chat.dto.ts @@ -0,0 +1,100 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsArray, + IsOptional, + IsNumber, + Min, + Max, + ValidateNested, + IsIn, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ChatMessageDto { + @ApiProperty({ description: 'Message role', enum: ['system', 'user', 'assistant'] }) + @IsString() + @IsIn(['system', 'user', 'assistant']) + role: 'system' | 'user' | 'assistant'; + + @ApiProperty({ description: 'Message content' }) + @IsString() + content: string; +} + +export class ChatRequestDto { + @ApiProperty({ description: 'Array of chat messages', type: [ChatMessageDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ChatMessageDto) + messages: ChatMessageDto[]; + + @ApiPropertyOptional({ description: 'Model to use (e.g., anthropic/claude-3-haiku)' }) + @IsOptional() + @IsString() + model?: string; + + @ApiPropertyOptional({ description: 'Temperature (0-2)', default: 0.7 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @ApiPropertyOptional({ description: 'Maximum tokens to generate', default: 2048 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(32000) + max_tokens?: number; + + @ApiPropertyOptional({ description: 'Top P sampling (0-1)', default: 1.0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + top_p?: number; + + @ApiPropertyOptional({ description: 'Stream response', default: false }) + @IsOptional() + stream?: boolean; +} + +export class ChatChoiceDto { + @ApiProperty() + index: number; + + @ApiProperty() + message: ChatMessageDto; + + @ApiProperty() + finish_reason: string; +} + +export class UsageDto { + @ApiProperty() + prompt_tokens: number; + + @ApiProperty() + completion_tokens: number; + + @ApiProperty() + total_tokens: number; +} + +export class ChatResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + model: string; + + @ApiProperty({ type: [ChatChoiceDto] }) + choices: ChatChoiceDto[]; + + @ApiProperty() + usage: UsageDto; + + @ApiProperty() + created: number; +} diff --git a/apps/backend/src/modules/ai/dto/config.dto.ts b/apps/backend/src/modules/ai/dto/config.dto.ts new file mode 100644 index 00000000..a4e7b484 --- /dev/null +++ b/apps/backend/src/modules/ai/dto/config.dto.ts @@ -0,0 +1,149 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsNumber, + IsBoolean, + Min, + Max, + IsEnum, + IsObject, +} from 'class-validator'; +import { AIProvider } from '../entities'; + +export class UpdateAIConfigDto { + @ApiPropertyOptional({ description: 'AI provider', enum: AIProvider }) + @IsOptional() + @IsEnum(AIProvider) + provider?: AIProvider; + + @ApiPropertyOptional({ description: 'Default model to use' }) + @IsOptional() + @IsString() + default_model?: string; + + @ApiPropertyOptional({ description: 'Fallback model if default fails' }) + @IsOptional() + @IsString() + fallback_model?: string; + + @ApiPropertyOptional({ description: 'Temperature (0-2)', default: 0.7 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @ApiPropertyOptional({ description: 'Maximum tokens', default: 2048 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(32000) + max_tokens?: number; + + @ApiPropertyOptional({ description: 'Default system prompt' }) + @IsOptional() + @IsString() + system_prompt?: string; + + @ApiPropertyOptional({ description: 'Enable AI features' }) + @IsOptional() + @IsBoolean() + is_enabled?: boolean; + + @ApiPropertyOptional({ description: 'Allow custom prompts' }) + @IsOptional() + @IsBoolean() + allow_custom_prompts?: boolean; + + @ApiPropertyOptional({ description: 'Log conversations' }) + @IsOptional() + @IsBoolean() + log_conversations?: boolean; + + @ApiPropertyOptional({ description: 'Additional settings' }) + @IsOptional() + @IsObject() + settings?: Record; +} + +export class AIConfigResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + tenant_id: string; + + @ApiProperty({ enum: AIProvider }) + provider: AIProvider; + + @ApiProperty() + default_model: string; + + @ApiPropertyOptional() + fallback_model?: string; + + @ApiProperty() + temperature: number; + + @ApiProperty() + max_tokens: number; + + @ApiPropertyOptional() + system_prompt?: string; + + @ApiProperty() + is_enabled: boolean; + + @ApiProperty() + allow_custom_prompts: boolean; + + @ApiProperty() + log_conversations: boolean; + + @ApiProperty() + created_at: Date; + + @ApiProperty() + updated_at: Date; +} + +export class UsageStatsDto { + @ApiProperty() + request_count: number; + + @ApiProperty() + total_input_tokens: number; + + @ApiProperty() + total_output_tokens: number; + + @ApiProperty() + total_tokens: number; + + @ApiProperty() + total_cost: number; + + @ApiProperty() + avg_latency_ms: number; +} + +export class AIModelDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty() + provider: string; + + @ApiProperty() + context_length: number; + + @ApiProperty() + pricing: { + prompt: number; + completion: number; + }; +} diff --git a/apps/backend/src/modules/ai/dto/index.ts b/apps/backend/src/modules/ai/dto/index.ts new file mode 100644 index 00000000..fb4f4ba9 --- /dev/null +++ b/apps/backend/src/modules/ai/dto/index.ts @@ -0,0 +1,14 @@ +export { + ChatMessageDto, + ChatRequestDto, + ChatChoiceDto, + UsageDto, + ChatResponseDto, +} from './chat.dto'; + +export { + UpdateAIConfigDto, + AIConfigResponseDto, + UsageStatsDto, + AIModelDto, +} from './config.dto'; diff --git a/apps/backend/src/modules/ai/entities/ai-config.entity.ts b/apps/backend/src/modules/ai/entities/ai-config.entity.ts new file mode 100644 index 00000000..6ee20953 --- /dev/null +++ b/apps/backend/src/modules/ai/entities/ai-config.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum AIProvider { + OPENROUTER = 'openrouter', + OPENAI = 'openai', + ANTHROPIC = 'anthropic', + GOOGLE = 'google', +} + +@Entity({ name: 'configs', schema: 'ai' }) +export class AIConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'enum', enum: AIProvider, default: AIProvider.OPENROUTER }) + provider: AIProvider; + + @Column({ type: 'varchar', length: 100, default: 'anthropic/claude-3-haiku' }) + default_model: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + fallback_model: string; + + @Column({ type: 'numeric', precision: 3, scale: 2, default: 0.7 }) + temperature: number; + + @Column({ type: 'int', default: 2048 }) + max_tokens: number; + + @Column({ type: 'numeric', precision: 3, scale: 2, default: 1.0, nullable: true }) + top_p: number; + + @Column({ type: 'numeric', precision: 3, scale: 2, default: 0.0, nullable: true }) + frequency_penalty: number; + + @Column({ type: 'numeric', precision: 3, scale: 2, default: 0.0, nullable: true }) + presence_penalty: number; + + @Column({ type: 'text', nullable: true }) + system_prompt: string; + + @Column({ type: 'int', nullable: true }) + rate_limit_requests_per_minute: number; + + @Column({ type: 'int', nullable: true }) + rate_limit_tokens_per_minute: number; + + @Column({ type: 'int', nullable: true }) + rate_limit_tokens_per_month: number; + + @Column({ type: 'boolean', default: true }) + is_enabled: boolean; + + @Column({ type: 'boolean', default: true }) + allow_custom_prompts: boolean; + + @Column({ type: 'boolean', default: false }) + log_conversations: boolean; + + @Column({ type: 'jsonb', default: {} }) + settings: Record; + + @CreateDateColumn({ type: 'timestamptz' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updated_at: Date; +} diff --git a/apps/backend/src/modules/ai/entities/ai-usage.entity.ts b/apps/backend/src/modules/ai/entities/ai-usage.entity.ts new file mode 100644 index 00000000..bf76741a --- /dev/null +++ b/apps/backend/src/modules/ai/entities/ai-usage.entity.ts @@ -0,0 +1,90 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; +import { AIProvider } from './ai-config.entity'; + +export enum AIModelType { + CHAT = 'chat', + COMPLETION = 'completion', + EMBEDDING = 'embedding', + IMAGE = 'image', +} + +export enum UsageStatus { + PENDING = 'pending', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +@Entity({ name: 'usage', schema: 'ai' }) +export class AIUsage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + user_id: string; + + @Column({ type: 'enum', enum: AIProvider }) + provider: AIProvider; + + @Column({ type: 'varchar', length: 100 }) + model: string; + + @Column({ type: 'enum', enum: AIModelType, default: AIModelType.CHAT }) + model_type: AIModelType; + + @Column({ type: 'enum', enum: UsageStatus, default: UsageStatus.PENDING }) + status: UsageStatus; + + @Column({ type: 'int', default: 0 }) + input_tokens: number; + + @Column({ type: 'int', default: 0 }) + output_tokens: number; + + // total_tokens is computed in DB, but we can add it for convenience + get total_tokens(): number { + return this.input_tokens + this.output_tokens; + } + + @Column({ type: 'numeric', precision: 12, scale: 6, default: 0 }) + cost_input: number; + + @Column({ type: 'numeric', precision: 12, scale: 6, default: 0 }) + cost_output: number; + + get cost_total(): number { + return Number(this.cost_input) + Number(this.cost_output); + } + + @Column({ type: 'int', nullable: true }) + latency_ms: number; + + @Column({ type: 'timestamptz', default: () => 'NOW()' }) + started_at: Date; + + @Column({ type: 'timestamptz', nullable: true }) + completed_at: Date; + + @Column({ type: 'varchar', length: 100, nullable: true }) + request_id: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + endpoint: string; + + @Column({ type: 'text', nullable: true }) + error_message: string; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamptz' }) + created_at: Date; +} diff --git a/apps/backend/src/modules/ai/entities/index.ts b/apps/backend/src/modules/ai/entities/index.ts new file mode 100644 index 00000000..6ed5c23d --- /dev/null +++ b/apps/backend/src/modules/ai/entities/index.ts @@ -0,0 +1,2 @@ +export { AIConfig, AIProvider } from './ai-config.entity'; +export { AIUsage, AIModelType, UsageStatus } from './ai-usage.entity'; diff --git a/apps/backend/src/modules/ai/index.ts b/apps/backend/src/modules/ai/index.ts new file mode 100644 index 00000000..3b3aa341 --- /dev/null +++ b/apps/backend/src/modules/ai/index.ts @@ -0,0 +1,6 @@ +export { AIModule } from './ai.module'; +export { AIController } from './ai.controller'; +export { AIService } from './services'; +export { OpenRouterClient } from './clients'; +export * from './entities'; +export * from './dto'; diff --git a/apps/backend/src/modules/ai/services/ai.service.ts b/apps/backend/src/modules/ai/services/ai.service.ts new file mode 100644 index 00000000..6e36be69 --- /dev/null +++ b/apps/backend/src/modules/ai/services/ai.service.ts @@ -0,0 +1,193 @@ +import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThanOrEqual } from 'typeorm'; +import { AIConfig, AIUsage, UsageStatus, AIProvider } from '../entities'; +import { OpenRouterClient } from '../clients'; +import { + ChatRequestDto, + ChatResponseDto, + UpdateAIConfigDto, + UsageStatsDto, + AIModelDto, +} from '../dto'; + +@Injectable() +export class AIService { + private readonly logger = new Logger(AIService.name); + + constructor( + @InjectRepository(AIConfig) + private readonly configRepository: Repository, + @InjectRepository(AIUsage) + private readonly usageRepository: Repository, + private readonly openRouterClient: OpenRouterClient, + ) {} + + // ==================== Configuration ==================== + + async getConfig(tenantId: string): Promise { + let config = await this.configRepository.findOne({ + where: { tenant_id: tenantId }, + }); + + // Create default config if not exists + if (!config) { + config = this.configRepository.create({ + tenant_id: tenantId, + provider: AIProvider.OPENROUTER, + default_model: 'anthropic/claude-3-haiku', + temperature: 0.7, + max_tokens: 2048, + is_enabled: true, + }); + await this.configRepository.save(config); + } + + return config; + } + + async updateConfig(tenantId: string, dto: UpdateAIConfigDto): Promise { + let config = await this.getConfig(tenantId); + + // Update fields + Object.assign(config, dto); + config.updated_at = new Date(); + + return this.configRepository.save(config); + } + + // ==================== Chat Completion ==================== + + async chat( + tenantId: string, + userId: string, + dto: ChatRequestDto, + ): Promise { + const config = await this.getConfig(tenantId); + + if (!config.is_enabled) { + throw new BadRequestException('AI features are disabled for this tenant'); + } + + if (!this.openRouterClient.isReady()) { + throw new BadRequestException('AI service is not configured'); + } + + // Create usage record + const usage = this.usageRepository.create({ + tenant_id: tenantId, + user_id: userId, + provider: config.provider, + model: dto.model || config.default_model, + status: UsageStatus.PENDING, + started_at: new Date(), + }); + await this.usageRepository.save(usage); + + try { + // Apply system prompt if configured and not provided + let messages = [...dto.messages]; + if (config.system_prompt && !messages.some((m) => m.role === 'system')) { + messages = [{ role: 'system', content: config.system_prompt }, ...messages]; + } + + const startTime = Date.now(); + + const response = await this.openRouterClient.chatCompletion( + { ...dto, messages }, + config.default_model, + config.temperature, + config.max_tokens, + ); + + const latencyMs = Date.now() - startTime; + + // Calculate costs + const costs = this.openRouterClient.calculateCost( + response.model, + response.usage.prompt_tokens, + response.usage.completion_tokens, + ); + + // Update usage record + usage.status = UsageStatus.COMPLETED; + usage.model = response.model; + usage.input_tokens = response.usage.prompt_tokens; + usage.output_tokens = response.usage.completion_tokens; + usage.cost_input = costs.input; + usage.cost_output = costs.output; + usage.latency_ms = latencyMs; + usage.completed_at = new Date(); + usage.request_id = response.id; + usage.endpoint = 'chat'; + await this.usageRepository.save(usage); + + return response; + } catch (error) { + // Record failure + usage.status = UsageStatus.FAILED; + usage.error_message = error.message; + usage.completed_at = new Date(); + await this.usageRepository.save(usage); + + throw error; + } + } + + // ==================== Models ==================== + + async getModels(): Promise { + return this.openRouterClient.getModels(); + } + + // ==================== Usage Stats ==================== + + async getCurrentMonthUsage(tenantId: string): Promise { + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const result = await this.usageRepository + .createQueryBuilder('usage') + .select('COUNT(*)', 'request_count') + .addSelect('COALESCE(SUM(usage.input_tokens), 0)', 'total_input_tokens') + .addSelect('COALESCE(SUM(usage.output_tokens), 0)', 'total_output_tokens') + .addSelect('COALESCE(SUM(usage.input_tokens + usage.output_tokens), 0)', 'total_tokens') + .addSelect('COALESCE(SUM(usage.cost_input + usage.cost_output), 0)', 'total_cost') + .addSelect('COALESCE(AVG(usage.latency_ms), 0)', 'avg_latency_ms') + .where('usage.tenant_id = :tenantId', { tenantId }) + .andWhere('usage.status = :status', { status: UsageStatus.COMPLETED }) + .andWhere('usage.created_at >= :startOfMonth', { startOfMonth }) + .getRawOne(); + + return { + request_count: parseInt(result.request_count, 10), + total_input_tokens: parseInt(result.total_input_tokens, 10), + total_output_tokens: parseInt(result.total_output_tokens, 10), + total_tokens: parseInt(result.total_tokens, 10), + total_cost: parseFloat(result.total_cost), + avg_latency_ms: parseFloat(result.avg_latency_ms), + }; + } + + async getUsageHistory( + tenantId: string, + page = 1, + limit = 20, + ): Promise<{ data: AIUsage[]; total: number }> { + const [data, total] = await this.usageRepository.findAndCount({ + where: { tenant_id: tenantId }, + order: { created_at: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total }; + } + + // ==================== Health Check ==================== + + isServiceReady(): boolean { + return this.openRouterClient.isReady(); + } +} diff --git a/apps/backend/src/modules/ai/services/index.ts b/apps/backend/src/modules/ai/services/index.ts new file mode 100644 index 00000000..a4310d7c --- /dev/null +++ b/apps/backend/src/modules/ai/services/index.ts @@ -0,0 +1 @@ +export { AIService } from './ai.service'; diff --git a/apps/database/ddl/01-schemas.sql b/apps/database/ddl/01-schemas.sql index 4959aaf1..d45e88fe 100644 --- a/apps/database/ddl/01-schemas.sql +++ b/apps/database/ddl/01-schemas.sql @@ -15,6 +15,7 @@ CREATE SCHEMA IF NOT EXISTS audit; CREATE SCHEMA IF NOT EXISTS notifications; CREATE SCHEMA IF NOT EXISTS feature_flags; CREATE SCHEMA IF NOT EXISTS storage; +CREATE SCHEMA IF NOT EXISTS ai; -- Comments COMMENT ON SCHEMA auth IS 'Authentication: sessions, tokens, OAuth'; @@ -26,3 +27,4 @@ COMMENT ON SCHEMA audit IS 'Audit: activity logs, audit trail'; COMMENT ON SCHEMA notifications IS 'Notifications: templates, queue, delivery logs'; COMMENT ON SCHEMA feature_flags IS 'Feature flags: flags, rollouts, experiments'; COMMENT ON SCHEMA storage IS 'Storage: files, metadata, CDN'; +COMMENT ON SCHEMA ai IS 'AI Integration: configurations, usage tracking, rate limits'; diff --git a/apps/database/ddl/02-enums.sql b/apps/database/ddl/02-enums.sql index 2473d1a1..b030b5e7 100644 --- a/apps/database/ddl/02-enums.sql +++ b/apps/database/ddl/02-enums.sql @@ -37,3 +37,8 @@ CREATE TYPE audit.severity AS ENUM ('info', 'warning', 'error', 'critical'); -- Storage enums CREATE TYPE storage.file_status AS ENUM ('uploading', 'processing', 'ready', 'failed', 'deleted'); CREATE TYPE storage.visibility AS ENUM ('private', 'tenant', 'public'); + +-- AI enums +CREATE TYPE ai.ai_provider AS ENUM ('openrouter', 'openai', 'anthropic', 'google'); +CREATE TYPE ai.ai_model_type AS ENUM ('chat', 'completion', 'embedding', 'image'); +CREATE TYPE ai.usage_status AS ENUM ('pending', 'completed', 'failed', 'cancelled'); diff --git a/apps/database/ddl/schemas/ai/_MAP.md b/apps/database/ddl/schemas/ai/_MAP.md new file mode 100644 index 00000000..d1207f49 --- /dev/null +++ b/apps/database/ddl/schemas/ai/_MAP.md @@ -0,0 +1,28 @@ +# Schema: ai + +AI Integration - Configuraciones, tracking de uso y rate limits. + +## Tablas + +| Archivo | Tabla | Descripcion | +|---------|-------|-------------| +| 01-ai-configs.sql | ai.configs | Configuracion AI por tenant | +| 02-ai-usage.sql | ai.usage | Tracking de uso de API calls | + +## Enums (en 02-enums.sql) + +- `ai.ai_provider` - openrouter, openai, anthropic, google +- `ai.ai_model_type` - chat, completion, embedding, image +- `ai.usage_status` - pending, completed, failed, cancelled + +## Vistas + +- `ai.monthly_usage` - Resumen mensual de uso por tenant + +## Funciones + +- `ai.get_current_month_usage(tenant_id)` - Uso del mes actual + +## RLS Policies + +Ambas tablas tienen Row Level Security habilitado con aislamiento por tenant. diff --git a/apps/database/ddl/schemas/ai/tables/01-ai-configs.sql b/apps/database/ddl/schemas/ai/tables/01-ai-configs.sql new file mode 100644 index 00000000..460beda0 --- /dev/null +++ b/apps/database/ddl/schemas/ai/tables/01-ai-configs.sql @@ -0,0 +1,67 @@ +-- ============================================ +-- AI Configurations Table +-- Per-tenant AI settings and defaults +-- ============================================ + +CREATE TABLE ai.configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + + -- Provider settings + provider ai.ai_provider NOT NULL DEFAULT 'openrouter', + default_model VARCHAR(100) NOT NULL DEFAULT 'anthropic/claude-3-haiku', + fallback_model VARCHAR(100) DEFAULT 'openai/gpt-3.5-turbo', + + -- Generation parameters + temperature NUMERIC(3,2) NOT NULL DEFAULT 0.7 CHECK (temperature >= 0 AND temperature <= 2), + max_tokens INTEGER NOT NULL DEFAULT 2048 CHECK (max_tokens > 0 AND max_tokens <= 32000), + top_p NUMERIC(3,2) DEFAULT 1.0 CHECK (top_p >= 0 AND top_p <= 1), + frequency_penalty NUMERIC(3,2) DEFAULT 0.0 CHECK (frequency_penalty >= -2 AND frequency_penalty <= 2), + presence_penalty NUMERIC(3,2) DEFAULT 0.0 CHECK (presence_penalty >= -2 AND presence_penalty <= 2), + + -- System prompt + system_prompt TEXT, + + -- Rate limits (override plan defaults) + rate_limit_requests_per_minute INTEGER, + rate_limit_tokens_per_minute INTEGER, + rate_limit_tokens_per_month INTEGER, + + -- Feature flags + is_enabled BOOLEAN NOT NULL DEFAULT true, + allow_custom_prompts BOOLEAN NOT NULL DEFAULT true, + log_conversations BOOLEAN NOT NULL DEFAULT false, + + -- Additional settings as JSON + settings JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Ensure one config per tenant + CONSTRAINT uq_ai_configs_tenant UNIQUE (tenant_id) +); + +-- Indexes +CREATE INDEX idx_ai_configs_tenant ON ai.configs(tenant_id); +CREATE INDEX idx_ai_configs_provider ON ai.configs(provider); + +-- RLS +ALTER TABLE ai.configs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY ai_configs_tenant_isolation ON ai.configs + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Trigger for updated_at +CREATE TRIGGER update_ai_configs_updated_at + BEFORE UPDATE ON ai.configs + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Comments +COMMENT ON TABLE ai.configs IS 'AI configuration per tenant'; +COMMENT ON COLUMN ai.configs.default_model IS 'Default model to use (e.g., anthropic/claude-3-haiku)'; +COMMENT ON COLUMN ai.configs.system_prompt IS 'Default system prompt for all AI interactions'; +COMMENT ON COLUMN ai.configs.settings IS 'Additional provider-specific settings as JSON'; diff --git a/apps/database/ddl/schemas/ai/tables/02-ai-usage.sql b/apps/database/ddl/schemas/ai/tables/02-ai-usage.sql new file mode 100644 index 00000000..6926ea38 --- /dev/null +++ b/apps/database/ddl/schemas/ai/tables/02-ai-usage.sql @@ -0,0 +1,124 @@ +-- ============================================ +-- AI Usage Tracking Table +-- Records each AI API call for billing/analytics +-- ============================================ + +CREATE TABLE ai.usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + + -- Request details + provider ai.ai_provider NOT NULL, + model VARCHAR(100) NOT NULL, + model_type ai.ai_model_type NOT NULL DEFAULT 'chat', + status ai.usage_status NOT NULL DEFAULT 'pending', + + -- Token counts + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER GENERATED ALWAYS AS (input_tokens + output_tokens) STORED, + + -- Cost tracking (in USD, 6 decimal precision) + cost_input NUMERIC(12,6) NOT NULL DEFAULT 0, + cost_output NUMERIC(12,6) NOT NULL DEFAULT 0, + cost_total NUMERIC(12,6) GENERATED ALWAYS AS (cost_input + cost_output) STORED, + + -- Performance metrics + latency_ms INTEGER, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + + -- Request metadata + request_id VARCHAR(100), + endpoint VARCHAR(50), + error_message TEXT, + + -- Additional context + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes for common queries +CREATE INDEX idx_ai_usage_tenant ON ai.usage(tenant_id); +CREATE INDEX idx_ai_usage_user ON ai.usage(user_id); +CREATE INDEX idx_ai_usage_tenant_created ON ai.usage(tenant_id, created_at DESC); +CREATE INDEX idx_ai_usage_model ON ai.usage(model); +CREATE INDEX idx_ai_usage_status ON ai.usage(status); +CREATE INDEX idx_ai_usage_created_at ON ai.usage(created_at DESC); + +-- Partial index for completed requests in current month (for billing) +CREATE INDEX idx_ai_usage_monthly ON ai.usage(tenant_id, created_at) + WHERE status = 'completed' + AND created_at >= date_trunc('month', CURRENT_DATE); + +-- RLS +ALTER TABLE ai.usage ENABLE ROW LEVEL SECURITY; + +CREATE POLICY ai_usage_tenant_isolation ON ai.usage + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Comments +COMMENT ON TABLE ai.usage IS 'AI API usage tracking for billing and analytics'; +COMMENT ON COLUMN ai.usage.input_tokens IS 'Number of input/prompt tokens'; +COMMENT ON COLUMN ai.usage.output_tokens IS 'Number of output/completion tokens'; +COMMENT ON COLUMN ai.usage.cost_input IS 'Cost for input tokens in USD'; +COMMENT ON COLUMN ai.usage.cost_output IS 'Cost for output tokens in USD'; +COMMENT ON COLUMN ai.usage.latency_ms IS 'Request latency in milliseconds'; + + +-- ============================================ +-- Monthly Usage Summary View +-- For efficient billing queries +-- ============================================ + +CREATE VIEW ai.monthly_usage AS +SELECT + tenant_id, + date_trunc('month', created_at) AS month, + COUNT(*) AS request_count, + SUM(input_tokens) AS total_input_tokens, + SUM(output_tokens) AS total_output_tokens, + SUM(input_tokens + output_tokens) AS total_tokens, + SUM(cost_input + cost_output) AS total_cost, + AVG(latency_ms) AS avg_latency_ms +FROM ai.usage +WHERE status = 'completed' +GROUP BY tenant_id, date_trunc('month', created_at); + +COMMENT ON VIEW ai.monthly_usage IS 'Monthly AI usage aggregation per tenant'; + + +-- ============================================ +-- Function: Get tenant usage for current month +-- ============================================ + +CREATE OR REPLACE FUNCTION ai.get_current_month_usage(p_tenant_id UUID) +RETURNS TABLE ( + request_count BIGINT, + total_input_tokens BIGINT, + total_output_tokens BIGINT, + total_tokens BIGINT, + total_cost NUMERIC, + avg_latency_ms NUMERIC +) AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(*)::BIGINT, + COALESCE(SUM(input_tokens), 0)::BIGINT, + COALESCE(SUM(output_tokens), 0)::BIGINT, + COALESCE(SUM(input_tokens + output_tokens), 0)::BIGINT, + COALESCE(SUM(cost_input + cost_output), 0)::NUMERIC, + COALESCE(AVG(latency_ms), 0)::NUMERIC + FROM ai.usage + WHERE tenant_id = p_tenant_id + AND status = 'completed' + AND created_at >= date_trunc('month', CURRENT_DATE); +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION ai.get_current_month_usage IS 'Get AI usage for current month for a tenant'; diff --git a/apps/frontend/src/components/index.ts b/apps/frontend/src/components/index.ts new file mode 100644 index 00000000..f90b75e5 --- /dev/null +++ b/apps/frontend/src/components/index.ts @@ -0,0 +1,2 @@ +// Notifications +export * from './notifications'; diff --git a/apps/frontend/src/components/notifications/NotificationBell.tsx b/apps/frontend/src/components/notifications/NotificationBell.tsx new file mode 100644 index 00000000..5f22d425 --- /dev/null +++ b/apps/frontend/src/components/notifications/NotificationBell.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { Bell } from 'lucide-react'; +import clsx from 'clsx'; +import { useUnreadNotificationsCount } from '@/hooks/useData'; +import { NotificationDrawer } from './NotificationDrawer'; + +export function NotificationBell() { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const { data } = useUnreadNotificationsCount(); + + const unreadCount = data?.count ?? 0; + const hasUnread = unreadCount > 0; + + return ( + <> + + + setIsDrawerOpen(false)} + /> + + ); +} diff --git a/apps/frontend/src/components/notifications/NotificationDrawer.tsx b/apps/frontend/src/components/notifications/NotificationDrawer.tsx new file mode 100644 index 00000000..86f3c213 --- /dev/null +++ b/apps/frontend/src/components/notifications/NotificationDrawer.tsx @@ -0,0 +1,117 @@ +import { X, Bell, CheckCheck, Loader2 } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import clsx from 'clsx'; +import { useNotifications, useMarkNotificationAsRead, useMarkAllNotificationsAsRead } from '@/hooks/useData'; +import { NotificationItem } from './NotificationItem'; + +interface NotificationDrawerProps { + isOpen: boolean; + onClose: () => void; +} + +export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps) { + const navigate = useNavigate(); + const { data, isLoading } = useNotifications(1, 20); + const markAsRead = useMarkNotificationAsRead(); + const markAllAsRead = useMarkAllNotificationsAsRead(); + + const notifications = data?.data ?? []; + const hasUnread = notifications.some((n) => !n.read_at); + + const handleMarkAsRead = (id: string) => { + markAsRead.mutate(id); + }; + + const handleMarkAllAsRead = () => { + markAllAsRead.mutate(); + }; + + const handleNavigate = (url: string) => { + onClose(); + navigate(url); + }; + + return ( + <> + {/* Backdrop */} + {isOpen && ( +
+ )} + + {/* Drawer */} +
+ {/* Header */} +
+
+ +

+ Notifications +

+
+
+ {hasUnread && ( + + )} + +
+
+ + {/* Content */} +
+ {isLoading ? ( +
+ +
+ ) : notifications.length === 0 ? ( +
+
+ +
+

+ No notifications +

+

+ You're all caught up! +

+
+ ) : ( +
+ {notifications.map((notification) => ( + + ))} +
+ )} +
+
+ + ); +} diff --git a/apps/frontend/src/components/notifications/NotificationItem.tsx b/apps/frontend/src/components/notifications/NotificationItem.tsx new file mode 100644 index 00000000..da822f08 --- /dev/null +++ b/apps/frontend/src/components/notifications/NotificationItem.tsx @@ -0,0 +1,83 @@ +import { formatDistanceToNow } from 'date-fns'; +import { Bell, AlertCircle, CheckCircle, Info, AlertTriangle } from 'lucide-react'; +import clsx from 'clsx'; +import type { Notification } from '@/hooks/useData'; + +interface NotificationItemProps { + notification: Notification; + onRead: (id: string) => void; + onNavigate?: (url: string) => void; +} + +const typeIcons: Record = { + info: Info, + success: CheckCircle, + warning: AlertTriangle, + error: AlertCircle, + default: Bell, +}; + +const typeColors: Record = { + info: 'text-blue-500 bg-blue-50 dark:bg-blue-900/20', + success: 'text-green-500 bg-green-50 dark:bg-green-900/20', + warning: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-900/20', + error: 'text-red-500 bg-red-50 dark:bg-red-900/20', + default: 'text-secondary-500 bg-secondary-50 dark:bg-secondary-800', +}; + +export function NotificationItem({ notification, onRead, onNavigate }: NotificationItemProps) { + const isRead = !!notification.read_at; + const Icon = typeIcons[notification.type] || typeIcons.default; + const colorClass = typeColors[notification.type] || typeColors.default; + + const handleClick = () => { + if (!isRead) { + onRead(notification.id); + } + // If notification has action_url, navigate + const actionUrl = (notification as any).action_url; + if (actionUrl && onNavigate) { + onNavigate(actionUrl); + } + }; + + return ( + + ); +} diff --git a/apps/frontend/src/components/notifications/index.ts b/apps/frontend/src/components/notifications/index.ts new file mode 100644 index 00000000..32fa5c9d --- /dev/null +++ b/apps/frontend/src/components/notifications/index.ts @@ -0,0 +1,3 @@ +export { NotificationBell } from './NotificationBell'; +export { NotificationDrawer } from './NotificationDrawer'; +export { NotificationItem } from './NotificationItem'; diff --git a/apps/frontend/src/layouts/DashboardLayout.tsx b/apps/frontend/src/layouts/DashboardLayout.tsx index fa2ec654..a63713d1 100644 --- a/apps/frontend/src/layouts/DashboardLayout.tsx +++ b/apps/frontend/src/layouts/DashboardLayout.tsx @@ -9,7 +9,6 @@ import { LogOut, Menu, X, - Bell, ChevronDown, Building2, Shield, @@ -17,6 +16,7 @@ import { } from 'lucide-react'; import { useState } from 'react'; import clsx from 'clsx'; +import { NotificationBell } from '@/components/notifications'; const navigation = [ { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, @@ -138,10 +138,7 @@ export function DashboardLayout() {
{/* Notifications */} - + {/* User menu */}
diff --git a/apps/frontend/src/pages/settings/GeneralSettings.tsx b/apps/frontend/src/pages/settings/GeneralSettings.tsx new file mode 100644 index 00000000..bad5af4a --- /dev/null +++ b/apps/frontend/src/pages/settings/GeneralSettings.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useAuthStore, useUIStore } from '@/stores'; +import toast from 'react-hot-toast'; +import { Loader2, User, Building, Moon, Sun, Monitor } from 'lucide-react'; + +interface ProfileFormData { + first_name: string; + last_name: string; + email: string; +} + +export function GeneralSettings() { + const { user } = useAuthStore(); + const { theme, setTheme } = useUIStore(); + const [isLoading, setIsLoading] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { + first_name: user?.first_name || '', + last_name: user?.last_name || '', + email: user?.email || '', + }, + }); + + const onSubmit = async (data: ProfileFormData) => { + try { + setIsLoading(true); + // API call would go here + await new Promise((resolve) => setTimeout(resolve, 1000)); + toast.success('Profile updated successfully!'); + } catch { + toast.error('Failed to update profile'); + } finally { + setIsLoading(false); + } + }; + + const themes = [ + { value: 'light', label: 'Light', icon: Sun }, + { value: 'dark', label: 'Dark', icon: Moon }, + { value: 'system', label: 'System', icon: Monitor }, + ] as const; + + return ( +
+ {/* Profile Settings */} +
+
+ +

+ Profile Information +

+
+
+
+
+
+ + + {errors.first_name && ( +

{errors.first_name.message}

+ )} +
+ +
+ + + {errors.last_name && ( +

{errors.last_name.message}

+ )} +
+
+ +
+ + +

+ Contact support to change your email address +

+
+ +
+ +
+
+
+
+ + {/* Organization Settings */} +
+
+ +

+ Organization +

+
+
+
+
+

+ Tenant ID +

+

+ {user?.tenant_id || 'N/A'} +

+
+
+
+
+ + {/* Appearance Settings */} +
+
+

+ Appearance +

+
+
+
+ {themes.map((t) => ( + + ))} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/pages/settings/NotificationSettings.tsx b/apps/frontend/src/pages/settings/NotificationSettings.tsx new file mode 100644 index 00000000..3483bb41 --- /dev/null +++ b/apps/frontend/src/pages/settings/NotificationSettings.tsx @@ -0,0 +1,186 @@ +import { Bell, Mail, Smartphone, MessageSquare, Loader2 } from 'lucide-react'; +import { useNotificationPreferences, useUpdateNotificationPreferences } from '@/hooks/useData'; + +interface NotificationChannel { + key: string; + label: string; + description: string; + icon: typeof Bell; +} + +const channels: NotificationChannel[] = [ + { + key: 'email_enabled', + label: 'Email Notifications', + description: 'Receive notifications via email', + icon: Mail, + }, + { + key: 'push_enabled', + label: 'Push Notifications', + description: 'Receive push notifications on your devices', + icon: Smartphone, + }, + { + key: 'in_app_enabled', + label: 'In-App Notifications', + description: 'See notifications within the application', + icon: Bell, + }, + { + key: 'sms_enabled', + label: 'SMS Notifications', + description: 'Receive important alerts via SMS', + icon: MessageSquare, + }, +]; + +interface NotificationCategory { + key: string; + label: string; + description: string; +} + +const categories: NotificationCategory[] = [ + { + key: 'security_alerts', + label: 'Security Alerts', + description: 'Login attempts, password changes, and security updates', + }, + { + key: 'product_updates', + label: 'Product Updates', + description: 'New features, improvements, and announcements', + }, + { + key: 'marketing_emails', + label: 'Marketing Emails', + description: 'Tips, offers, and promotional content', + }, +]; + +export function NotificationSettings() { + const { data: preferences, isLoading } = useNotificationPreferences(); + const updatePreferences = useUpdateNotificationPreferences(); + + const handleToggle = (key: string) => { + if (!preferences) return; + + const currentValue = (preferences as Record)[key] ?? false; + updatePreferences.mutate({ [key]: !currentValue }); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const prefs = (preferences || {}) as Record; + + return ( +
+ {/* Notification Channels */} +
+
+ +
+

+ Notification Channels +

+

+ Choose how you want to receive notifications +

+
+
+
+
+ {channels.map((channel) => ( +
+
+
+ +
+
+

+ {channel.label} +

+

+ {channel.description} +

+
+
+ +
+ ))} +
+
+
+ + {/* Notification Categories */} +
+
+

+ Notification Categories +

+

+ Control which types of notifications you receive +

+
+
+
+ {categories.map((category) => ( +
+
+

+ {category.label} +

+

+ {category.description} +

+
+ +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/pages/settings/SecuritySettings.tsx b/apps/frontend/src/pages/settings/SecuritySettings.tsx new file mode 100644 index 00000000..7f6214f7 --- /dev/null +++ b/apps/frontend/src/pages/settings/SecuritySettings.tsx @@ -0,0 +1,268 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { Shield, Key, Smartphone, AlertTriangle, Loader2, Eye, EyeOff } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { useChangePassword } from '@/hooks/useAuth'; + +interface PasswordFormData { + current_password: string; + new_password: string; + confirm_password: string; +} + +export function SecuritySettings() { + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const changePassword = useChangePassword(); + + const { + register, + handleSubmit, + watch, + reset, + formState: { errors }, + } = useForm(); + + const newPassword = watch('new_password'); + + const onSubmit = async (data: PasswordFormData) => { + try { + await changePassword.mutateAsync({ + currentPassword: data.current_password, + newPassword: data.new_password, + }); + reset(); + } catch { + // Error handled by mutation + } + }; + + return ( +
+ {/* Change Password */} +
+
+ +
+

+ Change Password +

+

+ Update your password to keep your account secure +

+
+
+
+
+
+ +
+ + +
+ {errors.current_password && ( +

{errors.current_password.message}

+ )} +
+ +
+ +
+ + +
+ {errors.new_password && ( +

{errors.new_password.message}

+ )} +
+ +
+ + + value === newPassword || 'Passwords do not match', + })} + /> + {errors.confirm_password && ( +

{errors.confirm_password.message}

+ )} +
+ +
+ +
+
+
+
+ + {/* Two-Factor Authentication */} +
+
+ +
+

+ Two-Factor Authentication +

+

+ Add an extra layer of security to your account +

+
+
+
+
+
+
+ +
+
+

+ 2FA is not enabled +

+

+ Protect your account with two-factor authentication +

+
+
+ +
+
+
+ + {/* Active Sessions */} +
+
+ +
+

+ Active Sessions +

+

+ Manage your active sessions across devices +

+
+
+
+
+
+
+

+ Current Session +

+

+ This device - Last active now +

+
+ + Active + +
+
+ +
+ +
+
+
+ + {/* Danger Zone */} +
+
+ +
+

+ Danger Zone +

+

+ Irreversible and destructive actions +

+
+
+
+
+
+

+ Delete Account +

+

+ Permanently delete your account and all associated data +

+
+ +
+
+
+
+ ); +} diff --git a/apps/frontend/src/pages/settings/SettingsPage.tsx b/apps/frontend/src/pages/settings/SettingsPage.tsx new file mode 100644 index 00000000..084ed129 --- /dev/null +++ b/apps/frontend/src/pages/settings/SettingsPage.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { Settings, User, Bell, Shield } from 'lucide-react'; +import clsx from 'clsx'; +import { GeneralSettings } from './GeneralSettings'; +import { NotificationSettings } from './NotificationSettings'; +import { SecuritySettings } from './SecuritySettings'; + +type TabKey = 'general' | 'notifications' | 'security'; + +interface Tab { + key: TabKey; + label: string; + icon: typeof Settings; + component: React.ComponentType; +} + +const tabs: Tab[] = [ + { + key: 'general', + label: 'General', + icon: User, + component: GeneralSettings, + }, + { + key: 'notifications', + label: 'Notifications', + icon: Bell, + component: NotificationSettings, + }, + { + key: 'security', + label: 'Security', + icon: Shield, + component: SecuritySettings, + }, +]; + +export function SettingsPage() { + const [activeTab, setActiveTab] = useState('general'); + + const ActiveComponent = tabs.find((t) => t.key === activeTab)?.component || GeneralSettings; + + return ( +
+ {/* Header */} +
+

+ Settings +

+

+ Manage your account settings and preferences +

+
+ + {/* Tabs */} +
+ +
+ + {/* Tab Content */} +
+ +
+
+ ); +} diff --git a/apps/frontend/src/pages/settings/index.ts b/apps/frontend/src/pages/settings/index.ts new file mode 100644 index 00000000..d541e19e --- /dev/null +++ b/apps/frontend/src/pages/settings/index.ts @@ -0,0 +1,4 @@ +export { SettingsPage } from './SettingsPage'; +export { GeneralSettings } from './GeneralSettings'; +export { NotificationSettings } from './NotificationSettings'; +export { SecuritySettings } from './SecuritySettings'; diff --git a/apps/frontend/src/router/index.tsx b/apps/frontend/src/router/index.tsx index 7c7b3d3b..7e66a6f9 100644 --- a/apps/frontend/src/router/index.tsx +++ b/apps/frontend/src/router/index.tsx @@ -12,10 +12,12 @@ import { ForgotPasswordPage } from '@/pages/auth/ForgotPasswordPage'; // Dashboard pages import { DashboardPage } from '@/pages/dashboard/DashboardPage'; -import { SettingsPage } from '@/pages/dashboard/SettingsPage'; import { BillingPage } from '@/pages/dashboard/BillingPage'; import { UsersPage } from '@/pages/dashboard/UsersPage'; +// Settings pages +import { SettingsPage } from '@/pages/settings'; + // Superadmin pages import { TenantsPage, TenantDetailPage, MetricsPage } from '@/pages/superadmin';