From 994fcdc4bf07f3689cf03a21097b11263ca93879 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Thu, 18 Sep 2025 07:08:07 +0200 Subject: [PATCH] gitignore issues --- .gitignore | 107 +-------- frontend/src/app/api/modules/route.ts | 25 +-- frontend/src/lib/error-utils.ts | 174 +++++++++++++++ frontend/src/lib/performance.ts | 301 ++++++++++++++++++++++++++ frontend/src/lib/playground-config.ts | 44 ++++ frontend/src/lib/url-utils.ts | 109 ++++++++++ frontend/src/lib/validation.ts | 289 +++++++++++++++++++++++++ 7 files changed, 922 insertions(+), 127 deletions(-) create mode 100644 frontend/src/lib/error-utils.ts create mode 100644 frontend/src/lib/performance.ts create mode 100644 frontend/src/lib/playground-config.ts create mode 100644 frontend/src/lib/url-utils.ts create mode 100644 frontend/src/lib/validation.ts diff --git a/.gitignore b/.gitignore index 1719a7d..74ddc32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,108 +1,3 @@ -*.backup -backend/storage/rag_documents/* -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# Virtual environments -venv/ -env/ -ENV/ -env.bak/ -venv.bak/ -.venv/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db - -# Node.js -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.npm -.eslintcache -.next/ -.nuxt/ -out/ -dist/ - -# Environment variables .env -.env.local -.env.development.local -.env.test.local -.env.production.local - -# Docker -*.log -docker-compose.override.yml - -# Database -*.db -*.sqlite - -# Redis -dump.rdb - -# Logs -logs/ -*.log - -# Coverage -coverage/ -.coverage -.nyc_output - -# Cache -.cache/ -.pytest_cache/ -.mypy_cache/ -.ruff_cache/ - -# Temporary files -*.tmp -*.temp -.tmp/ - -# Security - sensitive files backend/.config_encryption_key -*.key -*.pem -*.crt - -# Generated files -backend/performance_report.json -performance_report*.json +backend/storage/ diff --git a/frontend/src/app/api/modules/route.ts b/frontend/src/app/api/modules/route.ts index f8110a1..7419fd5 100644 --- a/frontend/src/app/api/modules/route.ts +++ b/frontend/src/app/api/modules/route.ts @@ -1,28 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' -import { proxyRequest, handleProxyResponse } from '@/lib/proxy-auth' +import { apiClient } from '@/lib/api-client' export async function GET() { try { - // Direct fetch instead of proxyRequest (proxyRequest had caching issues) - const baseUrl = process.env.INTERNAL_API_URL || `http://enclava-backend:${process.env.BACKEND_INTERNAL_PORT || '8000'}` - const url = `${baseUrl}/api/modules/` - const adminToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsImlzX3N1cGVydXNlciI6dHJ1ZSwicm9sZSI6InN1cGVyX2FkbWluIiwiZXhwIjoxNzg0Nzk2NDI2LjA0NDYxOX0.YOTlUY8nowkaLAXy5EKfnZEpbDgGCabru5R0jdq_DOQ' - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${adminToken}`, - 'Content-Type': 'application/json' - }, - // Disable caching to ensure fresh data - cache: 'no-store' - }) - - if (!response.ok) { - throw new Error(`Backend responded with ${response.status}: ${response.statusText}`) - } - - const data = await response.json() + // Use the authenticated API client which handles JWT tokens automatically + const data = await apiClient.get('/modules/') + return NextResponse.json(data) } catch (error) { return NextResponse.json( diff --git a/frontend/src/lib/error-utils.ts b/frontend/src/lib/error-utils.ts new file mode 100644 index 0000000..e03af93 --- /dev/null +++ b/frontend/src/lib/error-utils.ts @@ -0,0 +1,174 @@ +/** + * Utility functions for error handling and user feedback + */ + +export interface AppError { + code: string + message: string + details?: string + retryable?: boolean +} + +export const ERROR_CODES = { + NETWORK_ERROR: 'NETWORK_ERROR', + UNAUTHORIZED: 'UNAUTHORIZED', + VALIDATION_ERROR: 'VALIDATION_ERROR', + TIMEOUT_ERROR: 'TIMEOUT_ERROR', + SERVER_ERROR: 'SERVER_ERROR', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', +} as const + +/** + * Converts various error types into standardized AppError format + */ +export function normalizeError(error: unknown): AppError { + if (error instanceof Error) { + // Network or fetch errors + if (error.name === 'TypeError' && error.message.includes('fetch')) { + return { + code: ERROR_CODES.NETWORK_ERROR, + message: 'Unable to connect to server. Please check your internet connection.', + retryable: true + } + } + + // Timeout errors + if (error.name === 'AbortError' || error.message.includes('timeout')) { + return { + code: ERROR_CODES.TIMEOUT_ERROR, + message: 'Request timed out. Please try again.', + retryable: true + } + } + + return { + code: ERROR_CODES.UNKNOWN_ERROR, + message: error.message || 'An unexpected error occurred', + details: error.stack, + retryable: false + } + } + + if (typeof error === 'string') { + return { + code: ERROR_CODES.UNKNOWN_ERROR, + message: error, + retryable: false + } + } + + return { + code: ERROR_CODES.UNKNOWN_ERROR, + message: 'An unknown error occurred', + retryable: false + } +} + +/** + * Handles HTTP response errors + */ +export async function handleHttpError(response: Response): Promise { + let errorDetails: string + + try { + const errorData = await response.json() + errorDetails = errorData.error || errorData.message || 'Unknown error' + } catch { + try { + // Use the cloned response for text reading since original body was consumed + const responseClone = response.clone() + errorDetails = await responseClone.text() + } catch { + errorDetails = `HTTP ${response.status} error` + } + } + + switch (response.status) { + case 401: + return { + code: ERROR_CODES.UNAUTHORIZED, + message: 'You need to log in to continue', + details: errorDetails, + retryable: false + } + + case 400: + return { + code: ERROR_CODES.VALIDATION_ERROR, + message: 'Invalid request. Please check your input.', + details: errorDetails, + retryable: false + } + + case 429: + return { + code: ERROR_CODES.SERVER_ERROR, + message: 'Too many requests. Please wait a moment and try again.', + details: errorDetails, + retryable: true + } + + case 500: + case 502: + case 503: + case 504: + return { + code: ERROR_CODES.SERVER_ERROR, + message: 'Server error. Please try again in a moment.', + details: errorDetails, + retryable: true + } + + default: + return { + code: ERROR_CODES.SERVER_ERROR, + message: `Request failed (${response.status}): ${errorDetails}`, + details: errorDetails, + retryable: response.status >= 500 + } + } +} + +/** + * Retry wrapper with exponential backoff + */ +export async function withRetry( + fn: () => Promise, + options: { + maxAttempts?: number + initialDelay?: number + maxDelay?: number + backoffMultiplier?: number + } = {} +): Promise { + const { + maxAttempts = 3, + initialDelay = 1000, + maxDelay = 10000, + backoffMultiplier = 2 + } = options + + let lastError: unknown + let delay = initialDelay + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn() + } catch (error) { + lastError = error + + const appError = normalizeError(error) + + // Don't retry non-retryable errors + if (!appError.retryable || attempt === maxAttempts) { + throw error + } + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, delay)) + delay = Math.min(delay * backoffMultiplier, maxDelay) + } + } + + throw lastError +} \ No newline at end of file diff --git a/frontend/src/lib/performance.ts b/frontend/src/lib/performance.ts new file mode 100644 index 0000000..203d113 --- /dev/null +++ b/frontend/src/lib/performance.ts @@ -0,0 +1,301 @@ +/** + * Performance monitoring and optimization utilities + */ + +import React from 'react' + +export interface PerformanceMetric { + name: string + value: number + timestamp: number + metadata?: Record +} + +export interface PerformanceReport { + metrics: PerformanceMetric[] + summary: { + averageResponseTime: number + totalRequests: number + errorRate: number + slowestRequests: PerformanceMetric[] + } +} + +class PerformanceMonitor { + private metrics: PerformanceMetric[] = [] + private maxMetrics = 1000 // Keep last 1000 metrics + private enabled = process.env.NODE_ENV === 'development' + + /** + * Start timing an operation + */ + startTiming(name: string, metadata?: Record): () => void { + if (!this.enabled) { + return () => {} // No-op in production + } + + const startTime = performance.now() + + return () => { + const duration = performance.now() - startTime + this.recordMetric(name, duration, metadata) + } + } + + /** + * Record a performance metric + */ + recordMetric(name: string, value: number, metadata?: Record): void { + if (!this.enabled) return + + const metric: PerformanceMetric = { + name, + value, + timestamp: Date.now(), + metadata + } + + this.metrics.push(metric) + + // Keep only the most recent metrics + if (this.metrics.length > this.maxMetrics) { + this.metrics = this.metrics.slice(-this.maxMetrics) + } + + // Log slow operations + if (value > 1000) { // Slower than 1 second + } + } + + /** + * Measure and track API calls + */ + async trackApiCall( + name: string, + apiCall: () => Promise, + metadata?: Record + ): Promise { + const endTiming = this.startTiming(`api_${name}`, metadata) + + try { + const result = await apiCall() + endTiming() + return result + } catch (error) { + endTiming() + this.recordMetric(`api_${name}_error`, 1, { + ...metadata, + error: error instanceof Error ? error.message : 'Unknown error' + }) + throw error + } + } + + /** + * Track React component render times + */ + trackComponentRender(componentName: string, renderCount: number = 1): void { + this.recordMetric(`render_${componentName}`, renderCount) + } + + /** + * Get performance report + */ + getReport(): PerformanceReport { + const apiMetrics = this.metrics.filter(m => m.name.startsWith('api_')) + const errorMetrics = this.metrics.filter(m => m.name.includes('_error')) + + const totalRequests = apiMetrics.length + const errorRate = totalRequests > 0 ? (errorMetrics.length / totalRequests) * 100 : 0 + + const responseTimes = apiMetrics.map(m => m.value) + const averageResponseTime = responseTimes.length > 0 + ? responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length + : 0 + + const slowestRequests = [...apiMetrics] + .sort((a, b) => b.value - a.value) + .slice(0, 10) + + return { + metrics: this.metrics, + summary: { + averageResponseTime, + totalRequests, + errorRate, + slowestRequests + } + } + } + + /** + * Clear all metrics + */ + clear(): void { + this.metrics = [] + } + + /** + * Enable/disable monitoring + */ + setEnabled(enabled: boolean): void { + this.enabled = enabled + } + + /** + * Export metrics for analysis + */ + exportMetrics(): string { + return JSON.stringify({ + timestamp: Date.now(), + userAgent: navigator.userAgent, + metrics: this.metrics, + summary: this.getReport().summary + }, null, 2) + } +} + +// Global performance monitor instance +export const performanceMonitor = new PerformanceMonitor() + +/** + * React hook for component performance tracking + */ +export function usePerformanceTracking(componentName: string) { + const [renderCount, setRenderCount] = React.useState(0) + + React.useEffect(() => { + const newCount = renderCount + 1 + setRenderCount(newCount) + performanceMonitor.trackComponentRender(componentName, newCount) + }) + + return { + renderCount, + trackOperation: (name: string, metadata?: Record) => + performanceMonitor.startTiming(`${componentName}_${name}`, metadata), + + trackApiCall: (name: string, apiCall: () => Promise) => + performanceMonitor.trackApiCall(`${componentName}_${name}`, apiCall) + } +} + +/** + * Debounce utility for performance optimization + */ +export function debounce( + func: (...args: Args) => void, + delay: number +): (...args: Args) => void { + let timeoutId: NodeJS.Timeout | null = null + + return (...args: Args) => { + if (timeoutId) { + clearTimeout(timeoutId) + } + + timeoutId = setTimeout(() => { + func.apply(null, args) + }, delay) + } +} + +/** + * Throttle utility for performance optimization + */ +export function throttle( + func: (...args: Args) => void, + limit: number +): (...args: Args) => void { + let inThrottle = false + + return (...args: Args) => { + if (!inThrottle) { + func.apply(null, args) + inThrottle = true + setTimeout(() => inThrottle = false, limit) + } + } +} + +/** + * Memoization utility with performance tracking + */ +export function memoizeWithTracking( + fn: (...args: Args) => Return, + keyGenerator?: (...args: Args) => string +): (...args: Args) => Return { + const cache = new Map() + const cacheTimeout = 5 * 60 * 1000 // 5 minutes + + return (...args: Args) => { + const key = keyGenerator ? keyGenerator(...args) : JSON.stringify(args) + const now = Date.now() + + // Check cache + const cached = cache.get(key) + if (cached && (now - cached.timestamp) < cacheTimeout) { + performanceMonitor.recordMetric('memoize_hit', 1, { function: fn.name }) + return cached.result + } + + // Compute result + const endTiming = performanceMonitor.startTiming('memoize_compute', { function: fn.name }) + const result = fn(...args) + endTiming() + + // Store in cache + cache.set(key, { result, timestamp: now }) + performanceMonitor.recordMetric('memoize_miss', 1, { function: fn.name }) + + // Clean up old entries + if (cache.size > 100) { + const entries = Array.from(cache.entries()) + entries + .filter(([, value]) => (now - value.timestamp) > cacheTimeout) + .forEach(([key]) => cache.delete(key)) + } + + return result + } +} + +/** + * Web Vitals tracking + */ +export function trackWebVitals() { + if (typeof window === 'undefined') return + + // Track Largest Contentful Paint + if ('PerformanceObserver' in window) { + try { + new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (entry.entryType === 'largest-contentful-paint') { + performanceMonitor.recordMetric('lcp', entry.startTime) + } + if (entry.entryType === 'first-input') { + performanceMonitor.recordMetric('fid', (entry as any).processingStart - entry.startTime) + } + }) + }).observe({ entryTypes: ['largest-contentful-paint', 'first-input'] }) + } catch (error) { + } + } + + // Track Cumulative Layout Shift + if ('PerformanceObserver' in window) { + try { + let clsValue = 0 + new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (!(entry as any).hadRecentInput) { + clsValue += (entry as any).value + performanceMonitor.recordMetric('cls', clsValue) + } + }) + }).observe({ entryTypes: ['layout-shift'] }) + } catch (error) { + } + } +} \ No newline at end of file diff --git a/frontend/src/lib/playground-config.ts b/frontend/src/lib/playground-config.ts new file mode 100644 index 0000000..357da1c --- /dev/null +++ b/frontend/src/lib/playground-config.ts @@ -0,0 +1,44 @@ +// Centralized playground configuration +export const playgroundConfig = { + // Working models (avoiding rate-limited ones) + availableModels: [ + { + id: 'openrouter-gpt-4', + name: 'GPT-4 (OpenRouter)', + provider: 'OpenRouter', + category: 'chat', + status: 'available' + }, + { + id: 'openrouter-claude-3-sonnet', + name: 'Claude 3 Sonnet (OpenRouter)', + provider: 'OpenRouter', + category: 'chat', + status: 'available' + } + ], + + // Rate limited models to avoid + rateLimitedModels: [ + 'ollama-qwen3-235b', + 'ollama-gemini-2.0-flash', + 'ollama-gemini-2.5-pro' + ], + + // Default settings + defaults: { + model: 'openrouter-gpt-4', + temperature: 0.7, + maxTokens: 150, + systemPrompt: 'You are a helpful AI assistant.' + }, + + // Error handling + errorMessages: { + rateLimited: 'Model is currently rate limited. Please try another model.', + authFailed: 'Authentication failed. Please refresh the page.', + networkError: 'Network error. Please check your connection.' + } +} + +export default playgroundConfig \ No newline at end of file diff --git a/frontend/src/lib/url-utils.ts b/frontend/src/lib/url-utils.ts new file mode 100644 index 0000000..147d9a0 --- /dev/null +++ b/frontend/src/lib/url-utils.ts @@ -0,0 +1,109 @@ +/** + * URL utilities for handling HTTP/HTTPS protocol detection + */ + +/** + * Get the base URL with proper protocol detection + * This ensures API calls use the same protocol as the page was loaded with + */ +export const getBaseUrl = (): string => { + if (typeof window !== 'undefined') { + // Client-side: detect current protocol + const protocol = window.location.protocol === 'https:' ? 'https' : 'http' + const host = process.env.NEXT_PUBLIC_BASE_URL || window.location.hostname + return `${protocol}://${host}` + } + + // Server-side: default based on environment + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http' + return `${protocol}://${process.env.NEXT_PUBLIC_BASE_URL || 'localhost'}` +} + +/** + * Get the API URL with proper protocol detection + * This is the main function that should be used for all API calls + */ +export const getApiUrl = (): string => { + if (typeof window !== 'undefined') { + // Client-side: use the same protocol as the current page + const protocol = window.location.protocol.slice(0, -1) // Remove ':' from 'https:' + const host = window.location.hostname + return `${protocol}://${host}` + } + + // Server-side: default to HTTP for internal requests + return `http://${process.env.NEXT_PUBLIC_BASE_URL || 'localhost'}` +} + +/** + * Get the internal API URL for authenticated endpoints + * This ensures internal API calls use the same protocol as the page + */ +export const getInternalApiUrl = (): string => { + const baseUrl = getApiUrl() + return `${baseUrl}/api-internal` +} + +/** + * Get the public API URL for external client endpoints + * This ensures public API calls use the same protocol as the page + */ +export const getPublicApiUrl = (): string => { + const baseUrl = getApiUrl() + return `${baseUrl}/api` +} + +/** + * Helper function to make API calls with proper protocol + */ +export const apiFetch = async ( + endpoint: string, + options: RequestInit = {} +): Promise => { + const baseUrl = getApiUrl() + const url = `${baseUrl}${endpoint}` + + return fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) +} + +/** + * Helper function for internal API calls + */ +export const internalApiFetch = async ( + endpoint: string, + options: RequestInit = {} +): Promise => { + const url = `${getInternalApiUrl()}${endpoint}` + + return fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) +} + +/** + * Helper function for public API calls + */ +export const publicApiFetch = async ( + endpoint: string, + options: RequestInit = {} +): Promise => { + const url = `${getPublicApiUrl()}${endpoint}` + + return fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) +} \ No newline at end of file diff --git a/frontend/src/lib/validation.ts b/frontend/src/lib/validation.ts new file mode 100644 index 0000000..8baa4d1 --- /dev/null +++ b/frontend/src/lib/validation.ts @@ -0,0 +1,289 @@ +/** + * Validation utilities with TypeScript support + */ + +import type { ValidationRule, ValidationRules, ValidationResult } from '@/types/chatbot' + +/** + * Validates a single field against its rules + */ +export function validateField( + value: T, + rules: ValidationRule = {} +): string | null { + const { + required = false, + minLength, + maxLength, + min, + max, + pattern, + custom + } = rules + + // Required validation + if (required) { + if (value === null || value === undefined) { + return 'This field is required' + } + if (typeof value === 'string' && value.trim().length === 0) { + return 'This field is required' + } + if (Array.isArray(value) && value.length === 0) { + return 'This field is required' + } + } + + // Skip other validations if value is empty and not required + if (!required && (value === null || value === undefined || value === '')) { + return null + } + + // String length validation + if (typeof value === 'string') { + if (minLength !== undefined && value.length < minLength) { + return `Must be at least ${minLength} characters` + } + if (maxLength !== undefined && value.length > maxLength) { + return `Must be no more than ${maxLength} characters` + } + } + + // Number range validation + if (typeof value === 'number') { + if (min !== undefined && value < min) { + return `Must be at least ${min}` + } + if (max !== undefined && value > max) { + return `Must be no more than ${max}` + } + } + + // Array length validation + if (Array.isArray(value)) { + if (minLength !== undefined && value.length < minLength) { + return `Must have at least ${minLength} items` + } + if (maxLength !== undefined && value.length > maxLength) { + return `Must have no more than ${maxLength} items` + } + } + + // Pattern validation + if (typeof value === 'string' && pattern) { + if (!pattern.test(value)) { + return 'Invalid format' + } + } + + // Custom validation + if (custom) { + return custom(value) + } + + return null +} + +/** + * Validates an entire object against validation rules + */ +export function validateObject>( + obj: T, + rules: ValidationRules +): ValidationResult { + const errors: Record = {} + + for (const [key, rule] of Object.entries(rules)) { + if (rule && key in obj) { + const error = validateField(obj[key], rule as ValidationRule) + if (error) { + errors[key] = error + } + } + } + + return { + isValid: Object.keys(errors).length === 0, + errors + } +} + +/** + * Common validation rules for chatbot fields + */ +export const chatbotValidationRules = { + name: { + required: true, + minLength: 1, + maxLength: 100, + custom: (value: string) => { + if (!/^[a-zA-Z0-9\s\-_]+$/.test(value)) { + return 'Name can only contain letters, numbers, spaces, hyphens, and underscores' + } + return null + } + }, + + model: { + required: true, + minLength: 1, + maxLength: 100 + }, + + system_prompt: { + maxLength: 4000, + custom: (value: string) => { + if (value && value.trim().length === 0) { + return 'System prompt cannot be only whitespace' + } + return null + } + }, + + temperature: { + required: true, + min: 0, + max: 2 + }, + + max_tokens: { + required: true, + min: 1, + max: 4000 + }, + + memory_length: { + required: true, + min: 1, + max: 50 + }, + + rag_top_k: { + required: true, + min: 1, + max: 20 + }, + + fallback_responses: { + minLength: 1, + maxLength: 10, + custom: (responses: string[]) => { + if (responses.some(r => !r || r.trim().length === 0)) { + return 'All fallback responses must be non-empty' + } + return null + } + } +} as const + +/** + * Email validation rule + */ +export const emailRule: ValidationRule = { + pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + custom: (value: string) => { + if (value && !emailRule.pattern?.test(value)) { + return 'Please enter a valid email address' + } + return null + } +} + +/** + * URL validation rule + */ +export const urlRule: ValidationRule = { + pattern: /^https?:\/\/.+/, + custom: (value: string) => { + if (value && !urlRule.pattern?.test(value)) { + return 'Please enter a valid URL starting with http:// or https://' + } + return null + } +} + +/** + * Username validation rule + */ +export const usernameRule: ValidationRule = { + minLength: 3, + maxLength: 30, + pattern: /^[a-zA-Z0-9_-]+$/, + custom: (value: string) => { + if (value && !usernameRule.pattern?.test(value)) { + return 'Username can only contain letters, numbers, hyphens, and underscores' + } + return null + } +} + +/** + * Password validation rule + */ +export const passwordRule: ValidationRule = { + minLength: 8, + maxLength: 128, + custom: (value: string) => { + if (!value) return null + + if (!/(?=.*[a-z])/.test(value)) { + return 'Password must contain at least one lowercase letter' + } + if (!/(?=.*[A-Z])/.test(value)) { + return 'Password must contain at least one uppercase letter' + } + if (!/(?=.*\d)/.test(value)) { + return 'Password must contain at least one number' + } + if (!/(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\?])/.test(value)) { + return 'Password must contain at least one special character' + } + + return null + } +} + +/** + * Utility to create conditional validation rules + */ +export function when( + condition: (obj: any) => boolean, + rules: ValidationRule +): ValidationRule { + return { + ...rules, + custom: (value: T, obj?: any) => { + if (!condition(obj)) { + return null + } + + const originalCustom = rules.custom + if (originalCustom) { + return originalCustom(value, obj) + } + + return validateField(value, { ...rules, custom: undefined }) + } + } +} + +/** + * Debounced validation for real-time form validation + */ +export function createDebouncedValidator>( + rules: ValidationRules, + delay: number = 300 +) { + let timeoutId: NodeJS.Timeout | null = null + + return (obj: T, callback: (result: ValidationResult) => void) => { + if (timeoutId) { + clearTimeout(timeoutId) + } + + timeoutId = setTimeout(() => { + const result = validateObject(obj, rules) + callback(result) + }, delay) + } +} \ No newline at end of file