From 7c439361b7638fb99f3fb06330d0276e2e84e8f8 Mon Sep 17 00:00:00 2001 From: padreug Date: Fri, 5 Sep 2025 06:08:08 +0200 Subject: [PATCH] Refactor authentication and async operation handling in useAuth composable - Introduce useMultiAsyncOperation to manage multiple async operations in useAuth, enhancing error handling and loading state management. - Replace manual loading and error state management with standardized async operation patterns for initialize, login, register, and logout functions. - Update related components to utilize the new async operation structure, improving code clarity and maintainability. - Add useAsyncOperation to other composables (useChat, useTicketPurchase, useMarket) for consistent async handling across the application. --- src/composables/useAuth.ts | 124 ++++++------- src/core/composables/useAsyncOperation.ts | 164 ++++++++++++++++++ src/modules/chat/composables/useChat.ts | 49 +++--- .../events/composables/useTicketPurchase.ts | 43 +++-- src/modules/market/composables/useMarket.ts | 51 +++--- 5 files changed, 298 insertions(+), 133 deletions(-) create mode 100644 src/core/composables/useAsyncOperation.ts diff --git a/src/composables/useAuth.ts b/src/composables/useAuth.ts index 7d7058d..7728e51 100644 --- a/src/composables/useAuth.ts +++ b/src/composables/useAuth.ts @@ -1,31 +1,45 @@ import { ref, computed } from 'vue' import { lnbitsAPI, type User, type LoginCredentials, type RegisterData } from '@/lib/api/lnbits' +import { useMultiAsyncOperation } from '@/core/composables/useAsyncOperation' const currentUser = ref(null) -const isLoading = ref(false) -const error = ref(null) + +// Shared async operations for auth actions +const authOperations = useMultiAsyncOperation<{ + initialize: User | null + login: User + register: User + logout: void +}>() export function useAuth() { const isAuthenticated = computed(() => !!currentUser.value) + + // Get operation instances + const initializeOp = authOperations.createOperation('initialize') + const loginOp = authOperations.createOperation('login') + const registerOp = authOperations.createOperation('register') + const logoutOp = authOperations.createOperation('logout') /** * Initialize authentication on app start */ async function initialize(): Promise { try { - isLoading.value = true - error.value = null - - if (lnbitsAPI.isAuthenticated()) { - const user = await lnbitsAPI.getCurrentUser() - currentUser.value = user - } - } catch (err) { - error.value = err instanceof Error ? err.message : 'Failed to initialize authentication' - // Clear invalid token + await initializeOp.execute(async () => { + if (lnbitsAPI.isAuthenticated()) { + const user = await lnbitsAPI.getCurrentUser() + currentUser.value = user + return user + } + return null + }, { + errorMessage: 'Failed to initialize authentication', + showToast: false // Don't show toast for initialization errors + }) + } catch { + // Clear invalid token on error await logout() - } finally { - isLoading.value = false } } @@ -33,88 +47,77 @@ export function useAuth() { * Login with username and password */ async function login(credentials: LoginCredentials): Promise { - try { - isLoading.value = true - error.value = null - + await loginOp.execute(async () => { await lnbitsAPI.login(credentials) // Get user details const user = await lnbitsAPI.getCurrentUser() currentUser.value = user - } catch (err) { - error.value = err instanceof Error ? err.message : 'Login failed' - throw err - } finally { - isLoading.value = false - } + return user + }, { + errorMessage: 'Login failed' + }) } /** * Register new user */ async function register(data: RegisterData): Promise { - try { - isLoading.value = true - error.value = null - + await registerOp.execute(async () => { await lnbitsAPI.register(data) // Get user details const user = await lnbitsAPI.getCurrentUser() currentUser.value = user - } catch (err) { - error.value = err instanceof Error ? err.message : 'Registration failed' - throw err - } finally { - isLoading.value = false - } + return user + }, { + errorMessage: 'Registration failed' + }) } /** * Logout and clear user data */ async function logout(): Promise { - // Clear local state - lnbitsAPI.logout() - currentUser.value = null - error.value = null + await logoutOp.execute(async () => { + // Clear local state + lnbitsAPI.logout() + currentUser.value = null + // Clear all auth operation states + authOperations.clearAll() + }, { + showToast: false // Don't show toast for logout + }) } /** * Update user password */ async function updatePassword(currentPassword: string, newPassword: string): Promise { - try { - isLoading.value = true - error.value = null - + const updatePasswordOp = authOperations.createOperation('updatePassword' as any) + + return await updatePasswordOp.execute(async () => { const updatedUser = await lnbitsAPI.updatePassword(currentPassword, newPassword) currentUser.value = updatedUser - } catch (err) { - error.value = err instanceof Error ? err.message : 'Failed to update password' - throw err - } finally { - isLoading.value = false - } + return updatedUser + }, { + errorMessage: 'Failed to update password' + }) } /** * Update user profile */ async function updateProfile(data: Partial): Promise { - try { - isLoading.value = true - error.value = null - + const updateProfileOp = authOperations.createOperation('updateProfile' as any) + + return await updateProfileOp.execute(async () => { const updatedUser = await lnbitsAPI.updateProfile(data) currentUser.value = updatedUser - } catch (err) { - error.value = err instanceof Error ? err.message : 'Failed to update profile' - throw err - } finally { - isLoading.value = false - } + return updatedUser + }, { + errorMessage: 'Failed to update profile' + }) } /** @@ -145,8 +148,9 @@ export function useAuth() { // State currentUser: computed(() => currentUser.value), isAuthenticated, - isLoading, - error, + isLoading: computed(() => authOperations.isAnyLoading()), + error: computed(() => authOperations.hasAnyError() ? + (initializeOp.error.value || loginOp.error.value || registerOp.error.value || logoutOp.error.value) : null), userDisplay, // Actions diff --git a/src/core/composables/useAsyncOperation.ts b/src/core/composables/useAsyncOperation.ts new file mode 100644 index 0000000..7161028 --- /dev/null +++ b/src/core/composables/useAsyncOperation.ts @@ -0,0 +1,164 @@ +import { ref, type Ref } from 'vue' +import { toast } from 'vue-sonner' + +export interface AsyncOperationOptions { + successMessage?: string + errorMessage?: string + showToast?: boolean + showSuccessToast?: boolean + showErrorToast?: boolean +} + +export interface AsyncOperationState { + isLoading: Ref + error: Ref + data: Ref +} + +export interface AsyncOperationReturn extends AsyncOperationState { + execute: (operation: () => Promise, options?: AsyncOperationOptions) => Promise + reset: () => void + clear: () => void +} + +/** + * Composable for standardized async operation handling + * Eliminates duplicate loading/error/success patterns across modules + * + * @example + * ```typescript + * const { isLoading, error, data, execute } = useAsyncOperation() + * + * const handleOrder = async () => { + * await execute(async () => { + * return await createOrder(orderData) + * }, { + * successMessage: 'Order created successfully!', + * errorMessage: 'Failed to create order' + * }) + * } + * ``` + */ +export function useAsyncOperation(): AsyncOperationReturn { + const isLoading = ref(false) + const error = ref(null) + const data = ref(null) as Ref + + /** + * Execute an async operation with standardized error handling and loading states + */ + const execute = async ( + operation: () => Promise, + options: AsyncOperationOptions = {} + ): Promise => { + const { + successMessage, + errorMessage = 'Operation failed', + showToast = true, + showSuccessToast = showToast, + showErrorToast = showToast + } = options + + try { + isLoading.value = true + error.value = null + + const result = await operation() + data.value = result + + // Show success toast if configured + if (showSuccessToast && successMessage) { + toast.success(successMessage) + } + + return result + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err) + error.value = errorMsg + + // Show error toast if configured + if (showErrorToast) { + toast.error(errorMessage, { + description: errorMsg !== errorMessage ? errorMsg : undefined + }) + } + + // Re-throw to allow caller to handle if needed + throw err + } finally { + isLoading.value = false + } + } + + /** + * Reset the operation state (clear error, keep data) + */ + const reset = (): void => { + isLoading.value = false + error.value = null + } + + /** + * Clear all state (error, data, loading) + */ + const clear = (): void => { + isLoading.value = false + error.value = null + data.value = null + } + + return { + // State + isLoading, + error, + data, + + // Methods + execute, + reset, + clear + } +} + +/** + * Specialized version for operations that don't return data + */ +export function useAsyncAction(): Omit, 'data'> { + const { data, ...rest } = useAsyncOperation() + return rest +} + +/** + * Multiple async operations manager + * Useful when you need to track multiple independent operations + */ +export function useMultiAsyncOperation>() { + const operations = ref>>({} as any) + + const createOperation = (key: K): AsyncOperationReturn => { + if (!operations.value[key]) { + operations.value[key] = useAsyncOperation() + } + return operations.value[key] as AsyncOperationReturn + } + + const isAnyLoading = (): boolean => { + return Object.values(operations.value).some((op: any) => op.isLoading.value) + } + + const hasAnyError = (): boolean => { + return Object.values(operations.value).some((op: any) => op.error.value !== null) + } + + const clearAll = (): void => { + Object.values(operations.value).forEach((op: any) => op.clear()) + } + + return { + operations, + createOperation, + isAnyLoading, + hasAnyError, + clearAll + } +} \ No newline at end of file diff --git a/src/modules/chat/composables/useChat.ts b/src/modules/chat/composables/useChat.ts index 070beee..4bd6a93 100644 --- a/src/modules/chat/composables/useChat.ts +++ b/src/modules/chat/composables/useChat.ts @@ -2,6 +2,7 @@ import { ref, computed } from 'vue' import { injectService } from '@/core/di-container' import type { ChatService } from '../services/chat-service' import type { ChatPeer } from '../types' +import { useMultiAsyncOperation } from '@/core/composables/useAsyncOperation' // Service token for chat service export const CHAT_SERVICE_TOKEN = Symbol('chatService') @@ -14,8 +15,15 @@ export function useChat() { } const selectedPeer = ref(null) - const isLoading = ref(false) - const error = ref(null) + + // Async operations + const asyncOps = useMultiAsyncOperation<{ + sendMessage: void + refreshPeers: void + }>() + + const sendMessageOp = asyncOps.createOperation('sendMessage') + const refreshPeersOp = asyncOps.createOperation('refreshPeers') // Computed properties const peers = computed(() => chatService.allPeers.value) @@ -41,17 +49,11 @@ export function useChat() { return } - isLoading.value = true - error.value = null - - try { - await chatService.sendMessage(selectedPeer.value, content.trim()) - } catch (err) { - error.value = err instanceof Error ? err.message : 'Failed to send message' - console.error('Send message error:', err) - } finally { - isLoading.value = false - } + return await sendMessageOp.execute(async () => { + await chatService.sendMessage(selectedPeer.value!, content.trim()) + }, { + errorMessage: 'Failed to send message' + }) } const addPeer = (pubkey: string, name?: string): ChatPeer => { @@ -63,23 +65,22 @@ export function useChat() { } const refreshPeers = async () => { - isLoading.value = true - error.value = null - try { + return await refreshPeersOp.execute(async () => { await chatService.refreshPeers() - } catch (err) { - error.value = err instanceof Error ? err.message : 'Failed to refresh peers' - console.error('Failed to refresh peers:', err) - } finally { - isLoading.value = false - } + }, { + errorMessage: 'Failed to refresh peers' + }) } return { // State selectedPeer, - isLoading, - error, + isSendingMessage: sendMessageOp.isLoading, + sendMessageError: sendMessageOp.error, + isRefreshingPeers: refreshPeersOp.isLoading, + refreshPeersError: refreshPeersOp.error, + isLoading: computed(() => asyncOps.isAnyLoading()), + error: computed(() => sendMessageOp.error.value || refreshPeersOp.error.value), // Computed peers, diff --git a/src/modules/events/composables/useTicketPurchase.ts b/src/modules/events/composables/useTicketPurchase.ts index 86474cd..30cc322 100644 --- a/src/modules/events/composables/useTicketPurchase.ts +++ b/src/modules/events/composables/useTicketPurchase.ts @@ -2,13 +2,15 @@ import { ref, computed, onUnmounted } from 'vue' import { purchaseTicket, checkPaymentStatus, payInvoiceWithWallet } from '@/lib/api/events' import { useAuth } from '@/composables/useAuth' import { toast } from 'vue-sonner' +import { useAsyncOperation } from '@/core/composables/useAsyncOperation' export function useTicketPurchase() { const { isAuthenticated, currentUser } = useAuth() + // Async operations + const purchaseOperation = useAsyncOperation() + // State - const isLoading = ref(false) - const error = ref(null) const paymentHash = ref(null) const paymentRequest = ref(null) const qrCode = ref(null) @@ -50,7 +52,7 @@ export function useTicketPurchase() { qrCode.value = dataUrl } catch (err) { console.error('Error generating QR code:', err) - error.value = 'Failed to generate QR code' + // Note: error handling is now managed by the purchaseOperation } } @@ -98,16 +100,15 @@ export function useTicketPurchase() { throw new Error('User must be authenticated to purchase tickets') } - isLoading.value = true - error.value = null - paymentHash.value = null - paymentRequest.value = null - qrCode.value = null - ticketQRCode.value = null - purchasedTicketId.value = null - showTicketQR.value = false + return await purchaseOperation.execute(async () => { + // Clear previous state + paymentHash.value = null + paymentRequest.value = null + qrCode.value = null + ticketQRCode.value = null + purchasedTicketId.value = null + showTicketQR.value = false - try { // Get the invoice const invoice = await purchaseTicket(eventId) paymentHash.value = invoice.payment_hash @@ -133,12 +134,11 @@ export function useTicketPurchase() { // No wallet balance, proceed with manual payment await startPaymentStatusCheck(eventId, invoice.payment_hash) } - } catch (err) { - error.value = err instanceof Error ? err.message : 'Failed to purchase ticket' - console.error('Error purchasing ticket:', err) - } finally { - isLoading.value = false - } + + return invoice + }, { + errorMessage: 'Failed to purchase ticket' + }) } // Start payment status check @@ -184,8 +184,7 @@ export function useTicketPurchase() { // Reset payment state function resetPaymentState() { - isLoading.value = false - error.value = null + purchaseOperation.clear() paymentHash.value = null paymentRequest.value = null qrCode.value = null @@ -215,8 +214,8 @@ export function useTicketPurchase() { return { // State - isLoading, - error, + isLoading: purchaseOperation.isLoading, + error: purchaseOperation.error, paymentHash, paymentRequest, qrCode, diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts index 1e84f53..0659e2a 100644 --- a/src/modules/market/composables/useMarket.ts +++ b/src/modules/market/composables/useMarket.ts @@ -5,6 +5,7 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { config } from '@/lib/config' import { nostrmarketService } from '../services/nostrmarketService' import { nip04 } from 'nostr-tools' +import { useAsyncOperation } from '@/core/composables/useAsyncOperation' // Nostr event kinds for market functionality const MARKET_EVENT_KINDS = { @@ -44,9 +45,11 @@ export function useMarket() { } } + // Async operations + const marketOperation = useAsyncOperation() + const connectionOperation = useAsyncOperation() + // State - const isLoading = ref(false) - const error = ref(null) const isConnected = ref(false) const activeMarket = computed(() => marketStore.activeMarket) const markets = computed(() => marketStore.markets) @@ -56,20 +59,15 @@ export function useMarket() { // Connection state const connectionStatus = computed(() => { + if (connectionOperation.isLoading.value) return 'connecting' if (isConnected.value) return 'connected' - if (nostrStore.isConnecting) return 'connecting' - if (nostrStore.error) return 'error' + if (connectionOperation.error.value || nostrStore.error) return 'error' return 'disconnected' }) // Load market from naddr const loadMarket = async (naddr: string) => { - try { - isLoading.value = true - error.value = null - - // Load market from naddr - + return await marketOperation.execute(async () => { // Parse naddr to get market data // TODO: Confirm if this should use nostrStore.account?.pubkey or authService.user.value?.pubkey const marketData = { @@ -82,13 +80,10 @@ export function useMarket() { } await loadMarketData(marketData) - - } catch (err) { - error.value = err instanceof Error ? err : new Error('Failed to load market') - throw err - } finally { - isLoading.value = false - } + return marketData + }, { + errorMessage: 'Failed to load market' + }) } // Load market data from Nostr events @@ -560,7 +555,7 @@ export function useMarket() { // Connect to market const connectToMarket = async () => { - try { + return await connectionOperation.execute(async () => { console.log('🛒 Checking RelayHub connection...') // Use existing relay hub connection (should already be connected by base module) isConnected.value = relayHub.isConnected.value @@ -602,18 +597,18 @@ export function useMarket() { // Note: Order-related DMs are now handled by chat service forwarding // No need for separate subscription - - } catch (err) { - console.error('🛒 Failed to connect to market:', err) - error.value = err instanceof Error ? err : new Error('Failed to connect to market') - throw err - } + + return { isConnected: isConnected.value } + }, { + errorMessage: 'Failed to connect to market' + }) } // Disconnect from market const disconnectFromMarket = () => { isConnected.value = false - error.value = null + marketOperation.clear() + connectionOperation.clear() // Market disconnected } @@ -631,8 +626,10 @@ export function useMarket() { return { // State - isLoading: readonly(isLoading), - error: readonly(error), + isLoading: readonly(marketOperation.isLoading), + error: readonly(marketOperation.error), + isConnecting: readonly(connectionOperation.isLoading), + connectionError: readonly(connectionOperation.error), isConnected: readonly(isConnected), connectionStatus: readonly(connectionStatus), activeMarket: readonly(activeMarket),