diff --git a/src/services/ProfileService.ts b/src/services/ProfileService.ts index 690f03de..6d861c98 100644 --- a/src/services/ProfileService.ts +++ b/src/services/ProfileService.ts @@ -1,18 +1,23 @@ /** * ProfileService - Handles user profile operations and API calls * Extracted from AccountViewView.vue to improve separation of concerns + * + * @author Matthew Raymer + * @since 2025-08-25 */ -import { AxiosInstance, AxiosError } from "axios"; -import { UserProfile } from "@/libs/partnerServer"; -import { UserProfileResponse } from "@/interfaces/accountView"; -import { getHeaders, errorStringForLog } from "@/libs/endorserServer"; -import { handleApiError } from "./api"; -import { logger } from "@/utils/logger"; -import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView"; +import { AxiosInstance } from "axios"; +import { logger } from "../utils/logger"; +import { getServiceInitManager } from "./ServiceInitializationManager"; +import { + handleApiError, + createErrorContext, + createUserMessage, +} from "../utils/errorHandler"; +import { getHeaders } from "../libs/endorserServer"; /** - * Profile data interface + * Profile data structure */ export interface ProfileData { description: string; @@ -22,7 +27,10 @@ export interface ProfileData { } /** - * Profile service class + * Profile service for managing user profile information + * + * @author Matthew Raymer + * @since 2025-08-25 */ export class ProfileService { private axios: AxiosInstance; @@ -31,71 +39,192 @@ export class ProfileService { constructor(axios: AxiosInstance, partnerApiServer: string) { this.axios = axios; this.partnerApiServer = partnerApiServer; + + // Register with service initialization manager + const initManager = getServiceInitManager(); + initManager.registerService("ProfileService", [ + "AxiosInstance", + "PartnerApiServer", + ]); + + // Mark as initialized since constructor completed successfully + initManager.markInitialized("ProfileService"); + + logger.debug("[ProfileService] 🔧 Service initialized:", { + partnerApiServer, + hasAxios: !!axios, + timestamp: new Date().toISOString(), + }); } /** - * Load user profile from the server - * @param activeDid - The user's DID - * @returns ProfileData or null if profile doesn't exist + * Load user profile from the partner API + * + * @param did - User's DID + * @returns Profile data or null if not found + * @throws Error if API call fails */ - async loadProfile(activeDid: string): Promise { + async loadProfile(did: string): Promise { + const operation = "Load Profile"; + const context = createErrorContext("ProfileService", operation, { + did, + partnerApiServer: this.partnerApiServer, + endpoint: `${this.partnerApiServer}/api/partner/userProfileForIssuer/${did}`, + }); + try { - const headers = await getHeaders(activeDid); - const response = await this.axios.get( - `${this.partnerApiServer}/api/partner/userProfileForIssuer/${activeDid}`, - { headers }, - ); + // Enhanced request tracking + const requestId = `profile_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + logger.info("[ProfileService] 🔍 Loading profile:", { + requestId, + ...context, + }); + + // Get authentication headers + const headers = await getHeaders(did); + + // FIXED: Use the original working endpoint that was working before recent changes + // The working endpoint is /api/partner/userProfileForIssuer/{did} for getting a specific user's profile + // NOT /api/partner/userProfile which returns a list of all profiles + const fullUrl = `${this.partnerApiServer}/api/partner/userProfileForIssuer/${did}`; + + logger.info("[ProfileService] 🔗 Making API request:", { + requestId, + did, + fullUrl, + partnerApiServer: this.partnerApiServer, + hasAuthHeader: !!headers.Authorization, + authHeaderLength: headers.Authorization?.length || 0, + }); + + const response = await this.axios.get(fullUrl, { headers }); - if (response.status === 200) { - const data = response.data.data; - const profileData: ProfileData = { - description: data.description || "", - latitude: data.locLat || 0, - longitude: data.locLon || 0, - includeLocation: !!(data.locLat && data.locLon), + logger.info("[ProfileService] ✅ Profile loaded successfully:", { + requestId, + ...context, + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [], + responseData: response.data, + responseDataType: typeof response.data, + }); + + // FIXED: Use the original working response parsing logic + // The working endpoint returns a single profile object, not a list + if (response.data && response.data.data) { + const profileData = response.data.data; + logger.info("[ProfileService] 🔍 Parsing profile data:", { + requestId, + profileData, + profileDataKeys: Object.keys(profileData), + locLat: profileData.locLat, + locLon: profileData.locLon, + description: profileData.description, + issuerDid: profileData.issuerDid, + hasLocationFields: !!(profileData.locLat || profileData.locLon), + }); + + const result = { + description: profileData.description || "", + latitude: profileData.locLat || 0, + longitude: profileData.locLon || 0, + includeLocation: !!(profileData.locLat && profileData.locLon), }; - return profileData; + + logger.info("[ProfileService] 📊 Parsed profile result:", { + requestId, + result, + hasLocation: result.includeLocation, + locationValues: { + original: { locLat: profileData.locLat, locLon: profileData.locLon }, + parsed: { latitude: result.latitude, longitude: result.longitude }, + }, + }); + + return result; } else { - throw new Error(ACCOUNT_VIEW_CONSTANTS.ERRORS.UNABLE_TO_LOAD_PROFILE); + logger.warn("[ProfileService] âš ī¸ No profile data found in response:", { + requestId, + responseData: response.data, + hasData: !!response.data, + hasDataData: !!(response.data && response.data.data), + }); } - } catch (error) { - if (this.isApiError(error) && error.response?.status === 404) { - // Profile doesn't exist yet - this is normal + + return null; + } catch (error: unknown) { + // Use standardized error handling + const errorInfo = handleApiError(error, context, operation); + + // Handle specific HTTP status codes + if (errorInfo.errorType === "AxiosError" && errorInfo.status === 404) { + logger.info( + "[ProfileService] â„šī¸ Profile not found (404) - this is normal for new users", + ); return null; } - logger.error("Error loading profile:", errorStringForLog(error)); - handleApiError(error as AxiosError, "/api/partner/userProfileForIssuer"); - return null; + // Create user-friendly error message + const userMessage = createUserMessage( + errorInfo, + "Failed to load profile", + ); + throw new Error(userMessage); } } /** - * Save user profile to the server - * @param activeDid - The user's DID - * @param profileData - The profile data to save - * @returns true if successful, false otherwise + * Save user profile to the partner API + * + * @param did - User's DID + * @param profileData - Profile data to save + * @returns Success status + * @throws Error if API call fails */ - async saveProfile( - activeDid: string, - profileData: ProfileData, - ): Promise { + async saveProfile(did: string, profileData: ProfileData): Promise { + const operation = "Save Profile"; + const context = createErrorContext("ProfileService", operation, { + did, + partnerApiServer: this.partnerApiServer, + endpoint: `${this.partnerApiServer}/api/partner/userProfile`, + profileData, + }); + try { - const headers = await getHeaders(activeDid); - const payload: UserProfile = { - description: profileData.description, - issuerDid: activeDid, - }; + // Enhanced request tracking + const requestId = `profile_save_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + logger.info("[ProfileService] 💾 Saving profile:", { + requestId, + ...context, + }); + + // Get authentication headers + const headers = await getHeaders(did); - // Add location data if location is included - if ( - profileData.includeLocation && + // Prepare payload in the format expected by the partner API + const payload = { + description: profileData.description, + issuerDid: did, + ...(profileData.includeLocation && profileData.latitude && profileData.longitude - ) { - payload.locLat = profileData.latitude; - payload.locLon = profileData.longitude; - } + ? { + locLat: profileData.latitude, + locLon: profileData.longitude, + } + : {}), + }; + + logger.info("[ProfileService] 📤 Sending payload to server:", { + requestId, + payload, + hasLocation: profileData.includeLocation, + latitude: profileData.latitude, + longitude: profileData.longitude, + payloadKeys: Object.keys(payload), + }); const response = await this.axios.post( `${this.partnerApiServer}/api/partner/userProfile`, @@ -103,103 +232,32 @@ export class ProfileService { { headers }, ); - if (response.status === 201) { - return true; - } else { - logger.error("Error saving profile:", response); - throw new Error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_NOT_SAVED); - } - } catch (error) { - logger.error("Error saving profile:", errorStringForLog(error)); - handleApiError(error as AxiosError, "/api/partner/userProfile"); - return false; - } - } - - /** - * Delete user profile from the server - * @param activeDid - The user's DID - * @returns true if successful, false otherwise - */ - async deleteProfile(activeDid: string): Promise { - try { - const headers = await getHeaders(activeDid); - const url = `${this.partnerApiServer}/api/partner/userProfile`; - const response = await this.axios.delete(url, { headers }); - - if (response.status === 204 || response.status === 200) { - logger.info("Profile deleted successfully"); - return true; - } else { - logger.error("Unexpected response status when deleting profile:", { - status: response.status, - statusText: response.statusText, - data: response.data, - }); - throw new Error( - `Profile not deleted - HTTP ${response.status}: ${response.statusText}`, - ); - } - } catch (error) { - if (this.isApiError(error) && error.response) { - const response = error.response; - logger.error("API error deleting profile:", { - status: response.status, - statusText: response.statusText, - data: response.data, - url: this.getErrorUrl(error), - }); + logger.info("[ProfileService] ✅ Profile saved successfully:", { + requestId, + ...context, + status: response.status, + hasData: !!response.data, + responseData: response.data, + responseDataKeys: response.data ? Object.keys(response.data) : [], + }); - // Handle specific HTTP status codes - if (response.status === 204) { - return true; // 204 is success for DELETE operations - } else if (response.status === 404) { - logger.warn("Profile not found - may already be deleted"); - return true; // Consider this a success if profile doesn't exist - } else if (response.status === 400) { - logger.error("Bad request when deleting profile:", response.data); - const errorMessage = - typeof response.data === "string" - ? response.data - : response.data?.message || "Bad request"; - throw new Error(`Profile deletion failed: ${errorMessage}`); - } else if (response.status === 401) { - logger.error("Unauthorized to delete profile"); - throw new Error("You are not authorized to delete this profile"); - } else if (response.status === 403) { - logger.error("Forbidden to delete profile"); - throw new Error("You are not allowed to delete this profile"); - } - } + return true; + } catch (error: unknown) { + // Use standardized error handling + const errorInfo = handleApiError(error, context, operation); - logger.error("Error deleting profile:", errorStringForLog(error)); - handleApiError(error as AxiosError, "/api/partner/userProfile"); - return false; + // Create user-friendly error message + const userMessage = createUserMessage( + errorInfo, + "Failed to save profile", + ); + throw new Error(userMessage); } } /** - * Update profile location - * @param profileData - Current profile data - * @param latitude - New latitude - * @param longitude - New longitude - * @returns Updated profile data - */ - updateProfileLocation( - profileData: ProfileData, - latitude: number, - longitude: number, - ): ProfileData { - return { - ...profileData, - latitude, - longitude, - includeLocation: true, - }; - } - - /** - * Toggle location inclusion in profile + * Toggle profile location visibility + * * @param profileData - Current profile data * @returns Updated profile data */ @@ -215,6 +273,7 @@ export class ProfileService { /** * Clear profile location + * * @param profileData - Current profile data * @returns Updated profile data */ @@ -229,6 +288,7 @@ export class ProfileService { /** * Reset profile to default state + * * @returns Default profile data */ getDefaultProfile(): ProfileData { @@ -239,66 +299,27 @@ export class ProfileService { includeLocation: false, }; } - - /** - * Type guard for API errors with proper typing - */ - private isApiError(error: unknown): error is { - response?: { - status?: number; - statusText?: string; - data?: { message?: string } | string; - }; - } { - return typeof error === "object" && error !== null && "response" in error; - } - - /** - * Extract error URL safely from error object - */ - private getErrorUrl(error: unknown): string | undefined { - if (this.isAxiosError(error)) { - return error.config?.url; - } - if (this.isApiError(error) && this.hasConfigProperty(error)) { - const config = this.getConfigProperty(error); - return config?.url; - } - return undefined; - } - - /** - * Type guard to check if error has config property - */ - private hasConfigProperty( - error: unknown, - ): error is { config?: { url?: string } } { - return typeof error === "object" && error !== null && "config" in error; - } - - /** - * Safely extract config property from error - */ - private getConfigProperty(error: { - config?: { url?: string }; - }): { url?: string } | undefined { - return error.config; - } - - /** - * Type guard for AxiosError - */ - private isAxiosError(error: unknown): error is AxiosError { - return error instanceof AxiosError; - } } /** * Factory function to create a ProfileService instance + * + * @param axios - Axios instance for HTTP requests + * @param partnerApiServer - Partner API server URL + * @returns ProfileService instance */ export function createProfileService( axios: AxiosInstance, partnerApiServer: string, ): ProfileService { + // Register dependencies with service initialization manager + const initManager = getServiceInitManager(); + initManager.registerService("AxiosInstance", []); + initManager.registerService("PartnerApiServer", []); + + // Mark dependencies as initialized + initManager.markInitialized("AxiosInstance"); + initManager.markInitialized("PartnerApiServer"); + return new ProfileService(axios, partnerApiServer); } diff --git a/src/services/ServiceInitializationManager.ts b/src/services/ServiceInitializationManager.ts new file mode 100644 index 00000000..765c5e7a --- /dev/null +++ b/src/services/ServiceInitializationManager.ts @@ -0,0 +1,207 @@ +/** + * Service Initialization Manager + * + * Manages the proper initialization order of services to prevent race conditions + * and ensure dependencies are available when services are created. + * + * @author Matthew Raymer + * @since 2025-08-25 + */ + +import { logger } from "../utils/logger"; + +/** + * Service initialization status tracking + */ +interface ServiceStatus { + name: string; + initialized: boolean; + dependencies: string[]; + error?: string; +} + +/** + * Service initialization manager to prevent race conditions + */ +export class ServiceInitializationManager { + private static instance: ServiceInitializationManager; + private serviceStatuses = new Map(); + private initializationPromise: Promise | null = null; + + private constructor() {} + + /** + * Get singleton instance + */ + static getInstance(): ServiceInitializationManager { + if (!ServiceInitializationManager.instance) { + ServiceInitializationManager.instance = + new ServiceInitializationManager(); + } + return ServiceInitializationManager.instance; + } + + /** + * Register a service that needs initialization + */ + registerService(name: string, dependencies: string[] = []): void { + this.serviceStatuses.set(name, { + name, + initialized: false, + dependencies, + }); + + logger.debug("[ServiceInit] 🔧 Service registered:", { + name, + dependencies, + totalServices: this.serviceStatuses.size, + }); + } + + /** + * Mark a service as initialized + */ + markInitialized(name: string): void { + const status = this.serviceStatuses.get(name); + if (status) { + status.initialized = true; + logger.debug("[ServiceInit] ✅ Service initialized:", { + name, + totalInitialized: this.getInitializedCount(), + totalServices: this.serviceStatuses.size, + }); + } + } + + /** + * Mark a service as failed + */ + markFailed(name: string, error: string): void { + const status = this.serviceStatuses.get(name); + if (status) { + status.error = error; + logger.error("[ServiceInit] ❌ Service failed:", { + name, + error, + totalFailed: this.getFailedCount(), + }); + } + } + + /** + * Get count of initialized services + */ + private getInitializedCount(): number { + return Array.from(this.serviceStatuses.values()).filter( + (s) => s.initialized, + ).length; + } + + /** + * Get count of failed services + */ + private getFailedCount(): number { + return Array.from(this.serviceStatuses.values()).filter((s) => s.error) + .length; + } + + /** + * Wait for all services to be initialized + */ + async waitForInitialization(): Promise { + if (this.initializationPromise) { + return this.initializationPromise; + } + + this.initializationPromise = new Promise((resolve, reject) => { + const checkInterval = setInterval(() => { + const totalServices = this.serviceStatuses.size; + const initializedCount = this.getInitializedCount(); + const failedCount = this.getFailedCount(); + + logger.debug("[ServiceInit] 🔍 Initialization progress:", { + totalServices, + initializedCount, + failedCount, + remaining: totalServices - initializedCount - failedCount, + }); + + if (failedCount > 0) { + clearInterval(checkInterval); + const failedServices = Array.from(this.serviceStatuses.values()) + .filter((s) => s.error) + .map((s) => `${s.name}: ${s.error}`); + + const error = new Error( + `Service initialization failed: ${failedServices.join(", ")}`, + ); + logger.error("[ServiceInit] ❌ Initialization failed:", error); + reject(error); + } else if (initializedCount === totalServices) { + clearInterval(checkInterval); + logger.info( + "[ServiceInit] 🎉 All services initialized successfully:", + { + totalServices, + initializedCount, + }, + ); + resolve(); + } + }, 100); + + // Timeout after 30 seconds + setTimeout(() => { + clearInterval(checkInterval); + const error = new Error( + "Service initialization timeout after 30 seconds", + ); + logger.error("[ServiceInit] ⏰ Initialization timeout:", error); + reject(error); + }, 30000); + }); + + return this.initializationPromise; + } + + /** + * Get initialization status summary + */ + getStatusSummary(): { + total: number; + initialized: number; + failed: number; + pending: number; + services: ServiceStatus[]; + } { + const services = Array.from(this.serviceStatuses.values()); + const total = services.length; + const initialized = services.filter((s) => s.initialized).length; + const failed = services.filter((s) => s.error).length; + const pending = total - initialized - failed; + + return { + total, + initialized, + failed, + pending, + services, + }; + } + + /** + * Reset the manager (useful for testing) + */ + reset(): void { + this.serviceStatuses.clear(); + this.initializationPromise = null; + logger.debug("[ServiceInit] 🔄 Manager reset"); + } +} + +/** + * Convenience function to get the service initialization manager + */ +export const getServiceInitManager = (): ServiceInitializationManager => { + return ServiceInitializationManager.getInstance(); +}; diff --git a/src/utils/errorHandler.ts b/src/utils/errorHandler.ts new file mode 100644 index 00000000..ee608844 --- /dev/null +++ b/src/utils/errorHandler.ts @@ -0,0 +1,298 @@ +/** + * Standardized Error Handler + * + * Provides consistent error handling patterns across the TimeSafari codebase + * to improve debugging, user experience, and maintainability. + * + * @author Matthew Raymer + * @since 2025-08-25 + */ + +import { AxiosError } from "axios"; +import { logger } from "./logger"; + +/** + * Standard error context for consistent logging + */ +export interface ErrorContext { + component: string; + operation: string; + timestamp: string; + [key: string]: unknown; +} + +/** + * Enhanced error information for better debugging + */ +export interface EnhancedErrorInfo { + errorType: "AxiosError" | "NetworkError" | "ValidationError" | "UnknownError"; + status?: number; + statusText?: string; + errorData?: unknown; + errorMessage: string; + errorStack?: string; + requestContext?: { + url?: string; + method?: string; + headers?: Record; + }; +} + +/** + * Standardized error handler for API operations + * + * @param error - The error that occurred + * @param context - Context information about the operation + * @param operation - Description of the operation being performed + * @returns Enhanced error information for consistent handling + */ +export function handleApiError( + error: unknown, + context: ErrorContext, + operation: string, +): EnhancedErrorInfo { + const baseContext = { + ...context, + operation, + timestamp: new Date().toISOString(), + }; + + if (error instanceof AxiosError) { + const axiosError = error as AxiosError; + const status = axiosError.response?.status; + const statusText = axiosError.response?.statusText; + const errorData = axiosError.response?.data; + + const enhancedError: EnhancedErrorInfo = { + errorType: "AxiosError", + status, + statusText, + errorData, + errorMessage: axiosError.message, + errorStack: axiosError.stack, + requestContext: { + url: axiosError.config?.url, + method: axiosError.config?.method, + headers: axiosError.config?.headers, + }, + }; + + // Log with consistent format + logger.error( + `[${context.component}] ❌ ${operation} failed (AxiosError):`, + { + ...baseContext, + ...enhancedError, + }, + ); + + return enhancedError; + } + + if (error instanceof Error) { + const enhancedError: EnhancedErrorInfo = { + errorType: "UnknownError", + errorMessage: error.message, + errorStack: error.stack, + }; + + logger.error(`[${context.component}] ❌ ${operation} failed (Error):`, { + ...baseContext, + ...enhancedError, + }); + + return enhancedError; + } + + // Handle unknown error types + const enhancedError: EnhancedErrorInfo = { + errorType: "UnknownError", + errorMessage: String(error), + }; + + logger.error(`[${context.component}] ❌ ${operation} failed (Unknown):`, { + ...baseContext, + ...enhancedError, + }); + + return enhancedError; +} + +/** + * Extract human-readable error message from various error response formats + * + * @param errorData - Error response data + * @returns Human-readable error message + */ +export function extractErrorMessage(errorData: unknown): string { + if (typeof errorData === "string") { + return errorData; + } + + if (typeof errorData === "object" && errorData !== null) { + const obj = errorData as Record; + + // Try common error message fields + if (obj.message && typeof obj.message === "string") { + return obj.message; + } + + if (obj.error && typeof obj.error === "string") { + return obj.error; + } + + if (obj.detail && typeof obj.detail === "string") { + return obj.detail; + } + + if (obj.reason && typeof obj.reason === "string") { + return obj.reason; + } + + // Fallback to stringified object + return JSON.stringify(errorData); + } + + return String(errorData); +} + +/** + * Create user-friendly error message from enhanced error info + * + * @param errorInfo - Enhanced error information + * @param fallbackMessage - Fallback message if error details are insufficient + * @returns User-friendly error message + */ +export function createUserMessage( + errorInfo: EnhancedErrorInfo, + fallbackMessage: string, +): string { + if (errorInfo.errorType === "AxiosError") { + const status = errorInfo.status; + const statusText = errorInfo.statusText; + const errorMessage = extractErrorMessage(errorInfo.errorData); + + if (status && statusText) { + if (errorMessage && errorMessage !== "{}") { + return `${fallbackMessage}: ${status} ${statusText} - ${errorMessage}`; + } + return `${fallbackMessage}: ${status} ${statusText}`; + } + } + + if ( + errorInfo.errorMessage && + errorInfo.errorMessage !== "Request failed with status code 0" + ) { + return `${fallbackMessage}: ${errorInfo.errorMessage}`; + } + + return fallbackMessage; +} + +/** + * Handle specific HTTP status codes with appropriate user messages + * + * @param status - HTTP status code + * @param errorData - Error response data + * @param operation - Description of the operation + * @returns User-friendly error message + */ +export function handleHttpStatus( + status: number, + errorData: unknown, + operation: string, +): string { + const errorMessage = extractErrorMessage(errorData); + + switch (status) { + case 400: + return errorMessage || `${operation} failed: Bad request`; + case 401: + return `${operation} failed: Authentication required`; + case 403: + return `${operation} failed: Access denied`; + case 404: + return errorMessage || `${operation} failed: Resource not found`; + case 409: + return errorMessage || `${operation} failed: Conflict with existing data`; + case 422: + return errorMessage || `${operation} failed: Validation error`; + case 429: + return `${operation} failed: Too many requests. Please try again later.`; + case 500: + return `${operation} failed: Server error. Please try again later.`; + case 502: + case 503: + case 504: + return `${operation} failed: Service temporarily unavailable. Please try again later.`; + default: + return errorMessage || `${operation} failed: HTTP ${status}`; + } +} + +/** + * Check if an error is a network-related error + * + * @param error - The error to check + * @returns True if the error is network-related + */ +export function isNetworkError(error: unknown): boolean { + if (error instanceof AxiosError) { + return !error.response && !error.request; + } + + if (error instanceof Error) { + const message = error.message.toLowerCase(); + return ( + message.includes("network") || + message.includes("timeout") || + message.includes("connection") || + message.includes("fetch") + ); + } + + return false; +} + +/** + * Check if an error is a timeout error + * + * @param error - The error to check + * @returns True if the error is a timeout + */ +export function isTimeoutError(error: unknown): boolean { + if (error instanceof AxiosError) { + return ( + error.code === "ECONNABORTED" || + error.message.toLowerCase().includes("timeout") + ); + } + + if (error instanceof Error) { + return error.message.toLowerCase().includes("timeout"); + } + + return false; +} + +/** + * Create standardized error context for components + * + * @param component - Component name + * @param operation - Operation being performed + * @param additionalContext - Additional context information + * @returns Standardized error context + */ +export function createErrorContext( + component: string, + operation: string, + additionalContext: Record = {}, +): ErrorContext { + return { + component, + operation, + timestamp: new Date().toISOString(), + ...additionalContext, + }; +} diff --git a/src/utils/performanceOptimizer.ts b/src/utils/performanceOptimizer.ts new file mode 100644 index 00000000..8a77c599 --- /dev/null +++ b/src/utils/performanceOptimizer.ts @@ -0,0 +1,482 @@ +/** + * Performance Optimizer + * + * Provides utilities for optimizing API calls, database queries, and component + * rendering to improve TimeSafari application performance. + * + * @author Matthew Raymer + * @since 2025-08-25 + */ + +import { logger } from "./logger"; + +/** + * Batch operation configuration + */ +export interface BatchConfig { + maxBatchSize: number; + maxWaitTime: number; + retryAttempts: number; + retryDelay: number; +} + +/** + * Default batch configuration + */ +export const DEFAULT_BATCH_CONFIG: BatchConfig = { + maxBatchSize: 10, + maxWaitTime: 100, // milliseconds + retryAttempts: 3, + retryDelay: 1000, // milliseconds +}; + +/** + * Batched operation item + */ +export interface BatchItem { + id: string; + data: T; + resolve: (value: R) => void; + reject: (error: Error) => void; + timestamp: number; +} + +/** + * Batch processor for API operations + * + * Groups multiple similar operations into batches to reduce + * the number of API calls and improve performance. + */ +export class BatchProcessor { + private items: BatchItem[] = []; + private timer: NodeJS.Timeout | null = null; + private processing = false; + private config: BatchConfig; + + constructor( + private batchHandler: (items: T[]) => Promise, + private itemIdExtractor: (item: T) => string, + config: Partial = {}, + ) { + this.config = { ...DEFAULT_BATCH_CONFIG, ...config }; + } + + /** + * Add an item to the batch + * + * @param data - Data to process + * @returns Promise that resolves when the item is processed + */ + async add(data: T): Promise { + return new Promise((resolve, reject) => { + const item: BatchItem = { + id: this.itemIdExtractor(data), + data, + resolve, + reject, + timestamp: Date.now(), + }; + + this.items.push(item); + + // Start timer if this is the first item + if (this.items.length === 1) { + this.startTimer(); + } + + // Process immediately if batch is full + if (this.items.length >= this.config.maxBatchSize) { + this.processBatch(); + } + }); + } + + /** + * Start the batch timer + */ + private startTimer(): void { + if (this.timer) { + clearTimeout(this.timer); + } + + this.timer = setTimeout(() => { + this.processBatch(); + }, this.config.maxWaitTime); + } + + /** + * Process the current batch + */ + private async processBatch(): Promise { + if (this.processing || this.items.length === 0) { + return; + } + + this.processing = true; + + // Clear timer + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + // Get current batch + const currentItems = [...this.items]; + this.items = []; + + try { + logger.debug("[BatchProcessor] 🔄 Processing batch:", { + batchSize: currentItems.length, + itemIds: currentItems.map((item) => item.id), + timestamp: new Date().toISOString(), + }); + + // Process batch + const results = await this.batchHandler( + currentItems.map((item) => item.data), + ); + + // Map results back to items + const resultMap = new Map(); + results.forEach((result, index) => { + const item = currentItems[index]; + if (item) { + resultMap.set(item.id, result); + } + }); + + // Resolve promises + currentItems.forEach((item) => { + const result = resultMap.get(item.id); + if (result !== undefined) { + item.resolve(result); + } else { + item.reject(new Error(`No result found for item ${item.id}`)); + } + }); + + logger.debug("[BatchProcessor] ✅ Batch processed successfully:", { + batchSize: currentItems.length, + resultsCount: results.length, + timestamp: new Date().toISOString(), + }); + } catch (error) { + logger.error("[BatchProcessor] ❌ Batch processing failed:", { + batchSize: currentItems.length, + error: error instanceof Error ? error.message : String(error), + timestamp: new Date().toISOString(), + }); + + // Reject all items in the batch + currentItems.forEach((item) => { + item.reject(error instanceof Error ? error : new Error(String(error))); + }); + } finally { + this.processing = false; + + // Start timer for remaining items if any + if (this.items.length > 0) { + this.startTimer(); + } + } + } + + /** + * Get current batch status + */ + getStatus(): { + pendingItems: number; + isProcessing: boolean; + hasTimer: boolean; + } { + return { + pendingItems: this.items.length, + isProcessing: this.processing, + hasTimer: this.timer !== null, + }; + } + + /** + * Clear all pending items + */ + clear(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + // Reject all pending items + this.items.forEach((item) => { + item.reject(new Error("Batch processor cleared")); + }); + + this.items = []; + this.processing = false; + } +} + +/** + * Database query optimizer + * + * Provides utilities for optimizing database queries and reducing + * the number of database operations. + */ +export class DatabaseOptimizer { + /** + * Batch multiple SELECT queries into a single query + * + * @param baseQuery - Base SELECT query + * @param ids - Array of IDs to query + * @param idColumn - Name of the ID column + * @returns Optimized query string + */ + static batchSelectQuery( + baseQuery: string, + ids: (string | number)[], + idColumn: string, + ): string { + if (ids.length === 0) { + return baseQuery; + } + + if (ids.length === 1) { + return `${baseQuery} WHERE ${idColumn} = ?`; + } + + const placeholders = ids.map(() => "?").join(", "); + return `${baseQuery} WHERE ${idColumn} IN (${placeholders})`; + } + + /** + * Create a query plan for multiple operations + * + * @param operations - Array of database operations + * @returns Optimized query plan + */ + static createQueryPlan( + operations: Array<{ + type: "SELECT" | "INSERT" | "UPDATE" | "DELETE"; + table: string; + priority: number; + }>, + ): Array<{ + type: "SELECT" | "INSERT" | "UPDATE" | "DELETE"; + table: string; + priority: number; + batchable: boolean; + }> { + return operations + .map((op) => ({ + ...op, + batchable: op.type === "SELECT" || op.type === "INSERT", + })) + .sort((a, b) => { + // Sort by priority first, then by type + if (a.priority !== b.priority) { + return b.priority - a.priority; + } + + // SELECT operations first, then INSERT, UPDATE, DELETE + const typeOrder = { SELECT: 0, INSERT: 1, UPDATE: 2, DELETE: 3 }; + return typeOrder[a.type] - typeOrder[b.type]; + }); + } +} + +/** + * Component rendering optimizer + * + * Provides utilities for optimizing Vue component rendering + * and reducing unnecessary re-renders. + */ +export class ComponentOptimizer { + /** + * Debounce function calls to prevent excessive execution + * + * @param func - Function to debounce + * @param wait - Wait time in milliseconds + * @returns Debounced function + */ + static debounce unknown>( + func: T, + wait: number, + ): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null; + + return (...args: Parameters) => { + if (timeout) { + clearTimeout(timeout); + } + + timeout = setTimeout(() => { + func(...args); + }, wait); + }; + } + + /** + * Throttle function calls to limit execution frequency + * + * @param func - Function to throttle + * @param limit - Time limit in milliseconds + * @returns Throttled function + */ + static throttle unknown>( + func: T, + limit: number, + ): (...args: Parameters) => void { + let inThrottle = false; + + return (...args: Parameters) => { + if (!inThrottle) { + func(...args); + inThrottle = true; + setTimeout(() => { + inThrottle = false; + }, limit); + } + }; + } + + /** + * Memoize function results to avoid redundant computation + * + * @param func - Function to memoize + * @param keyGenerator - Function to generate cache keys + * @returns Memoized function + */ + static memoize unknown, K>( + func: T, + keyGenerator: (...args: Parameters) => K, + ): T { + const cache = new Map(); + + return ((...args: Parameters) => { + const key = keyGenerator(...args); + + if (cache.has(key)) { + return cache.get(key); + } + + const result = func(...args); + cache.set(key, result); + return result; + }) as T; + } +} + +/** + * Performance monitoring utility + * + * Tracks and reports performance metrics for optimization analysis. + */ +export class PerformanceMonitor { + private static instance: PerformanceMonitor; + private metrics = new Map< + string, + Array<{ timestamp: number; duration: number }> + >(); + + private constructor() {} + + /** + * Get singleton instance + */ + static getInstance(): PerformanceMonitor { + if (!PerformanceMonitor.instance) { + PerformanceMonitor.instance = new PerformanceMonitor(); + } + return PerformanceMonitor.instance; + } + + /** + * Start timing an operation + * + * @param operationName - Name of the operation + * @returns Function to call when operation completes + */ + startTiming(operationName: string): () => void { + const startTime = performance.now(); + + return () => { + const duration = performance.now() - startTime; + this.recordMetric(operationName, duration); + }; + } + + /** + * Record a performance metric + * + * @param operationName - Name of the operation + * @param duration - Duration in milliseconds + */ + private recordMetric(operationName: string, duration: number): void { + if (!this.metrics.has(operationName)) { + this.metrics.set(operationName, []); + } + + const operationMetrics = this.metrics.get(operationName)!; + operationMetrics.push({ + timestamp: Date.now(), + duration, + }); + + // Keep only last 100 metrics per operation + if (operationMetrics.length > 100) { + operationMetrics.splice(0, operationMetrics.length - 100); + } + } + + /** + * Get performance summary for an operation + * + * @param operationName - Name of the operation + * @returns Performance statistics + */ + getPerformanceSummary(operationName: string): { + count: number; + average: number; + min: number; + max: number; + recentAverage: number; + } | null { + const metrics = this.metrics.get(operationName); + if (!metrics || metrics.length === 0) { + return null; + } + + const durations = metrics.map((m) => m.duration); + const recentMetrics = metrics.slice(-10); // Last 10 metrics + + return { + count: metrics.length, + average: durations.reduce((a, b) => a + b, 0) / durations.length, + min: Math.min(...durations), + max: Math.max(...durations), + recentAverage: + recentMetrics.reduce((a, b) => a + b.duration, 0) / + recentMetrics.length, + }; + } + + /** + * Get all performance metrics + */ + getAllMetrics(): Map> { + return new Map(this.metrics); + } + + /** + * Clear all performance metrics + */ + clearMetrics(): void { + this.metrics.clear(); + } +} + +/** + * Convenience function to get the performance monitor + */ +export const getPerformanceMonitor = (): PerformanceMonitor => { + return PerformanceMonitor.getInstance(); +};