diff --git a/SALESFORCE_BACKEND_CALLBACK_UPDATE.md b/SALESFORCE_BACKEND_CALLBACK_UPDATE.md new file mode 100644 index 0000000..19b6971 --- /dev/null +++ b/SALESFORCE_BACKEND_CALLBACK_UPDATE.md @@ -0,0 +1,314 @@ +# ๐Ÿ”„ Salesforce OAuth - Backend Callback Flow Update + +## What Changed? + +The Salesforce OAuth implementation has been updated to use a **backend callback flow** instead of a mobile deep-link scheme. This means the backend server handles the OAuth callback and token exchange directly. + +--- + +## Previous Flow vs New Flow + +### โŒ Previous Flow (Deep Link) +``` +Salesforce โ†’ Mobile App (deep link) โ†’ Send code to backend โ†’ Backend exchanges token +``` +- **Redirect URI**: `centralizedreportingsystem://oauth/salesforce/callback` +- **Problem**: Custom scheme requires complex deep linking configuration +- **Risk**: Authorization code exposed to mobile app + +### โœ… New Flow (Backend Callback) +``` +Salesforce โ†’ Backend Server โ†’ Exchange token โ†’ Success page โ†’ Mobile app detects success +``` +- **Redirect URI**: `https://YOUR_BACKEND_URL/api/v1/users/oauth/callback?user_uuid=USER_ID&service_name=salesforce` +- **Benefits**: + - Backend handles token exchange securely + - No need for deep linking configuration + - Authorization code never exposed to mobile app + - Standard OAuth 2.0 web flow + +--- + +## Code Changes + +### SalesforceAuth.tsx + +#### 1. Updated Configuration +```typescript +// OLD +const SALESFORCE_CONFIG = { + CLIENT_ID: 'YOUR_CLIENT_ID', + REDIRECT_URI: 'centralizedreportingsystem://oauth/salesforce/callback', + AUTH_BASE_URL: 'https://login.salesforce.com', +}; + +// NEW +const SALESFORCE_CONFIG = { + CLIENT_ID: 'YOUR_CLIENT_ID', + BACKEND_BASE_URL: 'https://d5285bf63993.ngrok-free.app', + CALLBACK_PATH: '/api/v1/users/oauth/callback', + AUTH_BASE_URL: 'https://login.salesforce.com', +}; +``` + +#### 2. Updated OAuth URL Builder +```typescript +// OLD +const buildSalesforceAuthUrl = (): string => { + const redirectUri = 'centralizedreportingsystem://oauth/salesforce/callback'; + // ... +}; + +// NEW +const buildSalesforceAuthUrl = (userUuid: string): string => { + // Build redirect URI with query parameters for backend + const redirectUri = `${BACKEND_BASE_URL}${CALLBACK_PATH}?user_uuid=${userUuid}&service_name=salesforce`; + // ... +}; +``` + +#### 3. New Detection Functions +```typescript +// Check if URL is the backend callback +const isBackendCallbackUri = (url: string): boolean => { + return url.includes('/api/v1/users/oauth/callback'); +}; + +// Check for success +const isCallbackSuccess = (url: string): boolean => { + const status = getQueryParamFromUrl(url, 'status'); + return status === 'success'; +}; + +// Check for error +const isCallbackError = (url: string): boolean => { + const status = getQueryParamFromUrl(url, 'status'); + return status === 'error' || status === 'failure'; +}; +``` + +#### 4. Simplified Navigation Handler +```typescript +// OLD - Mobile app handled token exchange +handleNavigationStateChange = (navState) => { + if (isRedirectUri(url) && authCode) { + handleAuthorizationCode(authCode); // Send code to backend + } +}; + +// NEW - Backend handles token exchange +handleNavigationStateChange = (navState) => { + if (isBackendCallbackUri(url)) { + if (isCallbackSuccess(url)) { + handleBackendSuccess(); // Just close modal + } + if (isCallbackError(url)) { + handleBackendError(url); // Show error + } + } +}; +``` + +--- + +## Backend Implementation Required + +### Endpoint: `GET /api/v1/users/oauth/callback` + +Your backend **must** implement this endpoint to handle the OAuth callback from Salesforce. + +#### Query Parameters (Received from Salesforce): +```typescript +{ + user_uuid: string; // User ID (passed in redirect_uri) + service_name: string; // 'salesforce' (passed in redirect_uri) + code: string; // Authorization code from Salesforce +} +``` + +#### Backend Responsibilities: + +1. **Receive the callback** from Salesforce +2. **Extract** query parameters: `user_uuid`, `service_name`, `code` +3. **Exchange** authorization code for tokens with Salesforce: + ```http + POST https://login.salesforce.com/services/oauth2/token + grant_type=authorization_code + code={CODE} + client_id={CLIENT_ID} + client_secret={CLIENT_SECRET} + redirect_uri={CALLBACK_URL_WITH_PARAMS} + ``` +4. **Store** tokens (encrypted) in database +5. **Return** success/error HTML page: + - Success: Redirect to `?status=success` + - Error: Redirect to `?status=error&message=ERROR_MESSAGE` + +#### Example Response (Success): +```html + + +Success + +

โœ“ Authentication Successful!

+

Your Salesforce account has been connected.

+ + + +``` + +See [SALESFORCE_BACKEND_CALLBACK_FLOW.md](src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md) for complete backend implementation guide. + +--- + +## Configuration Steps + +### 1. Update Salesforce Connected App + +In your Salesforce Connected App settings: + +**Old Callback URL**: +``` +centralizedreportingsystem://oauth/salesforce/callback +``` + +**New Callback URL**: +``` +https://YOUR_BACKEND_URL/api/v1/users/oauth/callback +``` + +> Note: Don't include query parameters in the Salesforce Connected App config. The app adds them dynamically. + +### 2. Update Mobile App (Already Done โœ…) + +The `SalesforceAuth.tsx` component has been updated with: +- โœ… New backend callback URL configuration +- โœ… Detection logic for backend callback +- โœ… Success/error handling +- โœ… User UUID passed to backend + +### 3. Configure Backend + +Update your environment variables: + +```bash +# .env +SALESFORCE_CLIENT_ID=3MVG9GBhY6wQjl2sueQtv2NXMm3EuWtEvOQoeKRAzYcgs2... +SALESFORCE_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +BACKEND_URL=https://d5285bf63993.ngrok-free.app +ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +``` + +### 4. Implement Backend Endpoint + +Create the `/api/v1/users/oauth/callback` endpoint following the guide in [SALESFORCE_BACKEND_CALLBACK_FLOW.md](src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md). + +--- + +## Testing the Updated Flow + +### Test Checklist: + +1. **Start Backend Server** + - Ensure backend is running + - Verify callback endpoint is accessible + - For local testing, use ngrok: `ngrok http 4000` + +2. **Update Configuration** + - Update `BACKEND_BASE_URL` in `SalesforceAuth.tsx` + - Update Salesforce Connected App callback URL + - Restart mobile app + +3. **Test Authentication** + - Open app โ†’ CRM & Sales โ†’ Salesforce + - Complete Salesforce login + - **Expected**: Redirect to backend URL + - **Backend logs should show**: "Received callback", "Token exchange successful", "Tokens stored" + - **Mobile app should show**: Success message, then close modal + +4. **Verify Token Storage** + - Check database for stored tokens + - Verify tokens are encrypted + - Verify associated with correct user_id + +5. **Test Re-authentication** + - Tap "Re-auth" button + - Complete flow again + - Verify tokens are updated in database + +--- + +## Debugging + +### Check Logs + +#### Mobile App (React Native) +``` +[SalesforceAuth] Built OAuth URL: https://login.salesforce.com/... +[SalesforceAuth] Redirect URI: https://YOUR_BACKEND/callback?user_uuid=... +[SalesforceAuth] Backend callback detected +[SalesforceAuth] Backend callback indicates success +``` + +#### Backend Server +``` +[OAuth Callback] Received: { user_uuid: '...', service_name: 'salesforce', hasCode: true } +[OAuth Callback] Token exchange successful +[OAuth Callback] Tokens stored successfully +``` + +### Common Issues + +| Issue | Solution | +|-------|----------| +| Backend not reachable | Use ngrok for local testing: `ngrok http 4000` | +| Callback URL mismatch | Update Salesforce Connected App with exact backend URL | +| Token exchange fails | Verify CLIENT_SECRET is correct in backend | +| Mobile app doesn't detect success | Ensure backend returns HTML with `?status=success` | + +--- + +## Benefits of Backend Callback Flow + +โœ… **More Secure**: Authorization code never exposed to mobile app +โœ… **Simpler Configuration**: No deep linking setup needed +โœ… **Standard OAuth**: Follows standard web OAuth 2.0 flow +โœ… **Backend Control**: Full control over token exchange and storage +โœ… **Better Error Handling**: Backend can provide detailed error messages +โœ… **Token Refresh**: Backend can implement automatic token refresh + +--- + +## Migration Checklist + +- [x] Update `SalesforceAuth.tsx` configuration +- [x] Update OAuth URL builder to include user_uuid +- [x] Add backend callback detection logic +- [x] Simplify navigation handler +- [ ] **Implement backend `/oauth/callback` endpoint** +- [ ] **Update Salesforce Connected App callback URL** +- [ ] **Configure backend environment variables** +- [ ] **Test end-to-end flow** +- [ ] **Verify token storage in database** + +--- + +## Next Steps + +1. **Implement Backend Endpoint**: Follow the guide in [SALESFORCE_BACKEND_CALLBACK_FLOW.md](src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md) + +2. **Update Salesforce Config**: Change callback URL in Connected App settings + +3. **Test Thoroughly**: Test authentication flow with both success and error scenarios + +4. **Deploy**: Deploy backend changes and update mobile app configuration + +--- + +**Updated**: October 2025 +**Status**: โœ… Mobile App Updated - Backend Implementation Required +**Version**: 2.0.0 + diff --git a/SALESFORCE_INTEGRATION_SUMMARY.md b/SALESFORCE_INTEGRATION_SUMMARY.md new file mode 100644 index 0000000..93ef757 --- /dev/null +++ b/SALESFORCE_INTEGRATION_SUMMARY.md @@ -0,0 +1,380 @@ +# ๐ŸŽ‰ Salesforce Authentication Integration - Implementation Summary + +## Overview +Successfully implemented Salesforce OAuth authentication for the Centralized Reporting System, enabling users to authenticate with Salesforce through the CRM & Sales Integration category. + +--- + +## โœ… What Was Implemented + +### 1. **SalesforceAuth Component** (`src/modules/integrations/screens/SalesforceAuth.tsx`) + - Full OAuth 2.0 authentication flow using WebView + - Mobile-optimized with `display=touch` parameter + - Authorization code capture and exchange + - Comprehensive error handling + - Loading states with processing modal + - Deep linking support for OAuth callback + - Similar architecture to ZohoAuth for consistency + +### 2. **Updated IntegrationCategoryScreen** (`src/modules/integrations/screens/IntegrationCategoryScreen.tsx`) + - Added Salesforce authentication support + - Added `checkSalesforceToken()` function to verify existing tokens + - Updated `handleReAuthenticate()` to support both Zoho and Salesforce + - Added Salesforce Auth modal integration + - Enhanced authentication logic to distinguish between services + - Added re-authentication button for Salesforce + +### 3. **Deep Linking Configuration** + - **iOS**: Updated `Info.plist` with `CFBundleURLTypes` for custom URL scheme + - **Android**: Already configured in `AndroidManifest.xml` (existing deep linking support) + - URL Scheme: `centralizedreportingsystem://oauth/salesforce/callback` + +### 4. **Comprehensive Setup Guide** (`src/modules/integrations/screens/SALESFORCE_SETUP.md`) + - Step-by-step Salesforce Connected App creation + - OAuth configuration instructions + - Mobile app configuration guide + - Backend implementation guidance + - Troubleshooting section + - Security best practices + - Testing procedures + +--- + +## ๐Ÿ”ง Configuration Required + +### Step 1: Create Salesforce Connected App + +1. **Access Salesforce Setup** + - Log in to Salesforce (Production or Sandbox) + - Click gear icon (โš™๏ธ) โ†’ Setup + +2. **Create Connected App** + - Navigate to App Manager + - Click "New Connected App" + - Fill in basic information: + - **Name**: `Centralized Reporting System` + - **Contact Email**: Your email + +3. **Enable OAuth Settings** + - Check "Enable OAuth Settings" + - **Callback URL**: `centralizedreportingsystem://oauth/salesforce/callback` + - **Select OAuth Scopes**: + - Access the identity URL service (id, profile, email, address, phone) + - Access unique user identifiers (openid) + - Perform requests at any time (refresh_token, offline_access) + - Manage user data via APIs (api) + +4. **Save and Retrieve Credentials** + - Wait 2-10 minutes for processing + - Navigate back to App Manager โ†’ View your app + - Copy the **Consumer Key** (Client ID) + - Reveal and copy the **Consumer Secret** (Client Secret) + +### Step 2: Update Application Configuration + +Open `src/modules/integrations/screens/SalesforceAuth.tsx` and update: + +```typescript +const SALESFORCE_CONFIG = { + // โš ๏ธ REPLACE with your Consumer Key from Salesforce + CLIENT_ID: 'YOUR_CONSUMER_KEY_HERE', + + // This must match exactly what you configured in Salesforce + REDIRECT_URI: 'centralizedreportingsystem://oauth/salesforce/callback', + + // Production: https://login.salesforce.com + // Sandbox: https://test.salesforce.com + AUTH_BASE_URL: 'https://login.salesforce.com', + + RESPONSE_TYPE: 'code', + DISPLAY: 'touch', +}; +``` + +### Step 3: Backend Token Exchange + +Your backend needs to implement the token exchange endpoint: + +**Endpoint**: `POST /api/v1/integrations/manage-token` + +**Request Body**: +```json +{ + "authorization_code": "aPrxXXXXXXXXX", + "id": "user-uuid", + "service_name": "salesforce", + "access_token": "user-session-token" +} +``` + +**Backend Should**: +1. Receive the authorization code +2. Exchange it with Salesforce for an access token: + ```http + POST https://login.salesforce.com/services/oauth2/token + Content-Type: application/x-www-form-urlencoded + + grant_type=authorization_code + &code={AUTH_CODE} + &client_id={CLIENT_ID} + &client_secret={CLIENT_SECRET} + &redirect_uri={REDIRECT_URI} + ``` +3. Store tokens securely (encrypted) +4. Return success/failure response + +**Salesforce Response**: +```json +{ + "access_token": "00D...ABC", + "refresh_token": "5Aep...XYZ", + "instance_url": "https://yourinstance.salesforce.com", + "id": "https://login.salesforce.com/id/00D.../005...", + "token_type": "Bearer", + "issued_at": "1699999999999" +} +``` + +### Step 4: Optional - Data Sync Endpoint + +If you want to sync Salesforce data, implement: + +**Endpoint**: `POST /api/v1/integrations/salesforce/sync/schedule` + +This is called automatically after successful authentication to initiate data synchronization. + +--- + +## ๐Ÿš€ How to Use + +### For End Users: + +1. **Navigate to Integration** + - Open app โ†’ Integrations tab + - Tap "CRM & Sales Integration" + +2. **Select Salesforce** + - Tap on "Salesforce" service + - If not authenticated, Salesforce login screen will appear + +3. **Authenticate** + - Enter Salesforce credentials + - Complete 2FA if enabled + - Approve OAuth permissions + +4. **Success** + - Authorization code is captured + - Backend exchanges code for tokens + - Processing modal shows progress + - Modal closes automatically on success + +5. **Re-authenticate (Optional)** + - Tap the "Re-auth" button next to Salesforce + - Useful for changing organizations or refreshing permissions + +--- + +## ๐Ÿ—‚๏ธ File Structure + +``` +src/modules/integrations/screens/ +โ”œโ”€โ”€ SalesforceAuth.tsx # New: Salesforce OAuth component +โ”œโ”€โ”€ ZohoAuth.tsx # Existing: Zoho OAuth component +โ”œโ”€โ”€ IntegrationCategoryScreen.tsx # Updated: Added Salesforce support +โ”œโ”€โ”€ SALESFORCE_SETUP.md # New: Setup documentation +โ””โ”€โ”€ ... + +ios/CentralizedReportingSystem/ +โ””โ”€โ”€ Info.plist # Updated: Added CFBundleURLTypes + +android/app/src/main/ +โ””โ”€โ”€ AndroidManifest.xml # Already configured (no changes needed) +``` + +--- + +## ๐Ÿ”’ Security Considerations + +1. **Never Commit Credentials** + - Store Client ID and Secret in environment variables + - Use `.env` files (don't commit to Git) + - Example: + ```bash + # .env + SALESFORCE_CLIENT_ID=your_consumer_key + SALESFORCE_CLIENT_SECRET=your_consumer_secret + ``` + +2. **Backend Token Storage** + - Encrypt tokens before storing + - Use secure database encryption + - Implement token rotation + +3. **HTTPS Only** + - Always use `https://` for Salesforce APIs + - Never downgrade to HTTP in production + +4. **Minimal Scopes** + - Request only necessary OAuth scopes + - Avoid `full` access unless required + +5. **State Parameter** (Future Enhancement) + - Consider adding CSRF protection with state parameter + - Validate state on callback + +--- + +## ๐Ÿงช Testing Checklist + +- [ ] Create Salesforce Connected App +- [ ] Update CLIENT_ID in SalesforceAuth.tsx +- [ ] Backend token exchange endpoint is ready +- [ ] Build and run the app (both iOS and Android) +- [ ] Navigate to CRM & Sales Integration +- [ ] Tap Salesforce service +- [ ] Complete OAuth flow +- [ ] Verify authorization code is captured +- [ ] Check backend logs for successful token exchange +- [ ] Test re-authentication flow +- [ ] Test with Production Salesforce org +- [ ] Test with Sandbox Salesforce org (if applicable) +- [ ] Verify error handling (wrong credentials, network failure) +- [ ] Test deep linking callback on both platforms + +--- + +## ๐Ÿ› Common Issues & Solutions + +### Issue 1: "redirect_uri_mismatch" +**Solution**: Ensure the redirect URI in code exactly matches Salesforce Connected App configuration. + +### Issue 2: "invalid_client_id" +**Solution**: Wait 2-10 minutes after creating Connected App, or verify the Client ID is correct. + +### Issue 3: WebView Doesn't Load +**Solution**: Check internet connection, verify AUTH_BASE_URL is correct. + +### Issue 4: App Doesn't Capture Code +**Solution**: Verify deep linking is configured correctly in Info.plist and AndroidManifest.xml. + +### Issue 5: Backend Token Exchange Fails +**Solution**: Verify backend endpoint, check Consumer Secret, ensure authorization code hasn't expired (15 min limit). + +--- + +## ๐Ÿ“Š Architecture Flow + +``` +User Taps Salesforce + โ†“ +Check for Existing Token (checkSalesforceToken) + โ†“ + [No Token?] + โ†“ +Open SalesforceAuth Modal + โ†“ +Display Salesforce Login (WebView) + โ†“ +User Authenticates + โ†“ +Salesforce Redirects to: centralizedreportingsystem://oauth/salesforce/callback?code=... + โ†“ +App Captures Authorization Code + โ†“ +Send Code to Backend (manageToken API) + โ†“ +Backend Exchanges Code for Tokens (Salesforce API) + โ†“ +Backend Stores Tokens (Encrypted) + โ†“ +[Optional] Schedule Data Sync + โ†“ +Success! Close Modal + โ†“ +User is Authenticated +``` + +--- + +## ๐Ÿ“‹ Next Steps + +### Immediate (Required): +1. โœ… Create Salesforce Connected App +2. โœ… Update `CLIENT_ID` in `SalesforceAuth.tsx` +3. โœ… Implement backend token exchange endpoint +4. โœ… Test the integration end-to-end + +### Future Enhancements: +- [ ] Add token refresh logic +- [ ] Implement data synchronization from Salesforce +- [ ] Add Salesforce dashboard screens +- [ ] Support multiple Salesforce orgs per user +- [ ] Add offline token management +- [ ] Implement webhook support for real-time updates +- [ ] Add analytics for Salesforce data +- [ ] Create Salesforce-specific widgets + +--- + +## ๐Ÿ“š Resources + +### Documentation Files: +- **Setup Guide**: `src/modules/integrations/screens/SALESFORCE_SETUP.md` +- **Component**: `src/modules/integrations/screens/SalesforceAuth.tsx` +- **Integration Screen**: `src/modules/integrations/screens/IntegrationCategoryScreen.tsx` + +### External Resources: +- [Salesforce Connected Apps](https://help.salesforce.com/s/articleView?id=sf.connected_app_overview.htm) +- [Salesforce OAuth 2.0](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_flow.htm) +- [React Native WebView](https://github.com/react-native-webview/react-native-webview) + +--- + +## ๐ŸŽฏ Key Features + +โœ… Full OAuth 2.0 authentication flow +โœ… Mobile-optimized login experience +โœ… Automatic authorization code capture +โœ… Backend token exchange integration +โœ… Re-authentication support +โœ… Comprehensive error handling +โœ… Loading and processing states +โœ… Deep linking configuration +โœ… Production and Sandbox support +โœ… Consistent with existing Zoho integration + +--- + +## ๐Ÿค Support + +If you encounter issues: +1. Check the `SALESFORCE_SETUP.md` guide +2. Review console logs for error messages +3. Verify Salesforce Connected App configuration +4. Check backend logs for token exchange errors +5. Test with both Production and Sandbox environments + +--- + +**Implementation Date**: October 2025 +**Status**: โœ… Complete - Pending Configuration +**Version**: 1.0.0 + +--- + +## ๐Ÿ“ Notes for Developers + +- The Salesforce authentication follows the same pattern as Zoho authentication for consistency +- Authorization codes expire in 15 minutes - ensure timely backend exchange +- The `manageToken` API endpoint is reused for both Zoho and Salesforce (differentiated by `service_name` parameter) +- Deep linking is already configured for the app; Salesforce uses the same scheme +- The component includes comprehensive logging for debugging +- Error handling includes automatic retry functionality +- The processing modal provides user feedback during async operations + +--- + +**Ready to Deploy!** ๐Ÿš€ + +Once you've completed the configuration steps above, the Salesforce integration will be fully functional. Users will be able to authenticate with their Salesforce accounts directly from the CRM & Sales Integration category. + diff --git a/ios/CentralizedReportingSystem/Info.plist b/ios/CentralizedReportingSystem/Info.plist index 4cc9758..fec0c8e 100644 --- a/ios/CentralizedReportingSystem/Info.plist +++ b/ios/CentralizedReportingSystem/Info.plist @@ -56,5 +56,16 @@ Roboto-SemiBold.ttf Roboto-Thin.ttf + CFBundleURLTypes + + + CFBundleURLSchemes + + centralizedreportingsystem + + CFBundleURLName + com.centralizedreportingsystem + + diff --git a/src/modules/crm/navigation/CrmNavigator.tsx b/src/modules/crm/navigation/CrmNavigator.tsx index 64b888a..34ed106 100644 --- a/src/modules/crm/navigation/CrmNavigator.tsx +++ b/src/modules/crm/navigation/CrmNavigator.tsx @@ -1,16 +1,30 @@ import React from 'react'; import { createStackNavigator } from '@react-navigation/stack'; +import { useSelector } from 'react-redux'; +import type { RootState } from '@/store/store'; import CrmDashboardScreen from '@/modules/crm/zoho/screens/CrmDashboardScreen'; import ZohoCrmDataScreen from '@/modules/crm/zoho/screens/ZohoCrmDataScreen'; +import SalesforceCrmDashboardScreen from '@/modules/crm/salesforce/screens/SalesforceCrmDashboardScreen'; +import SalesforceDataScreen from '@/modules/crm/salesforce/screens/SalesforceDataScreen'; const Stack = createStackNavigator(); -const CrmNavigator = () => ( - - - - -); +const CrmNavigator = () => { + // Get the selected service from Redux to determine initial screen + const selectedService = useSelector((state: RootState) => state.integrations.selectedService); + + // Determine initial route based on selected CRM service + const initialRouteName = selectedService === 'salesforce' ? 'SalesforceDashboard' : 'CrmDashboard'; + + return ( + + + + + + + ); +}; export default CrmNavigator; diff --git a/src/modules/crm/salesforce/README.md b/src/modules/crm/salesforce/README.md new file mode 100644 index 0000000..be38cff --- /dev/null +++ b/src/modules/crm/salesforce/README.md @@ -0,0 +1,265 @@ +# Salesforce CRM Module + +## Overview +This module provides a complete Salesforce CRM integration for the Centralized Reporting System, including dashboard visualizations, data management, and comprehensive analytics. + +## Structure + +``` +salesforce/ +โ”œโ”€โ”€ components/ +โ”‚ โ””โ”€โ”€ SalesforceDataCards.tsx # Card components for each data type +โ”œโ”€โ”€ screens/ +โ”‚ โ”œโ”€โ”€ SalesforceCrmDashboardScreen.tsx # Dashboard with KPIs and charts +โ”‚ โ””โ”€โ”€ SalesforceDataScreen.tsx # Tabbed data view with all CRM data +โ”œโ”€โ”€ services/ +โ”‚ โ””โ”€โ”€ salesforceCrmAPI.ts # API service for Salesforce data +โ”œโ”€โ”€ store/ +โ”‚ โ”œโ”€โ”€ salesforceCrmSlice.ts # Redux slice with state management +โ”‚ โ””โ”€โ”€ selectors.ts # Redux selectors +โ”œโ”€โ”€ types/ +โ”‚ โ””โ”€โ”€ SalesforceCrmTypes.ts # TypeScript type definitions +โ””โ”€โ”€ index.ts # Module exports +``` + +## API Endpoints + +The module integrates with the following Salesforce CRM endpoints: + +- **Events**: `/api/v1/n8n/salesforce/crm/events` +- **Leads**: `/api/v1/n8n/salesforce/crm/leads` +- **Tasks**: `/api/v1/n8n/salesforce/crm/tasks` +- **Accounts**: `/api/v1/n8n/salesforce/crm/accounts` +- **Opportunities**: `/api/v1/n8n/salesforce/crm/opportunities` + +## Features + +### Dashboard Screen (`SalesforceCrmDashboardScreen`) +- **Hero Stats**: Large cards showing Leads and Opportunities totals +- **Pipeline Overview**: Total pipeline value, average deal size, and open deals +- **Module Counts**: Compact grid showing Events, Tasks, and Accounts counts +- **Lead Status Distribution**: Pie chart showing New, Working, and Qualified leads +- **Task Status**: Stacked bar chart showing Open, Completed, and High Priority tasks +- **Key Metrics**: Upcoming Events and Won Deals summary +- **Pull-to-refresh** functionality +- **Real-time data updates** + +### Data Screen (`SalesforceDataScreen`) +- **Tabbed Interface**: Switch between Events, Leads, Tasks, Accounts, and Opportunities +- **Data Cards**: Rich card display for each data type with relevant information +- **Infinite Scrolling**: Load more data as you scroll +- **Count Badges**: Show total count for each tab +- **Pull-to-refresh** functionality +- **Error handling and loading states** + +### Data Types + +#### Event +- Subject, Location, Description +- Start/End DateTime +- All Day Event indicator +- Created date + +#### Lead +- Name (First + Last) +- Company, Email, Phone +- Status (New, Working, Nurturing, Qualified, Unqualified) +- Title, Lead Source +- Created date + +#### Task +- Subject, Description +- Status (Open, In Progress, Completed, Waiting, Deferred) +- Priority (High, Normal, Low) +- Activity Date +- Created/Modified dates + +#### Account +- Name, Industry, Type +- Phone, Website +- Billing Address (City, State, Country) +- Annual Revenue +- Created date + +#### Opportunity +- Name, Stage, Type +- Amount, Probability +- Close Date, Forecast Category +- Created/Modified dates + +## Redux State Management + +### State Structure +```typescript +{ + events: SalesforceEvent[]; + leads: SalesforceLead[]; + tasks: SalesforceTask[]; + accounts: SalesforceAccount[]; + opportunities: SalesforceOpportunity[]; + loading: { /* loading states for each type */ }; + errors: { /* error states for each type */ }; + pagination: { /* pagination info for each type */ }; + counts: { /* total counts from API */ }; + lastUpdated: { /* last updated timestamps */ }; +} +``` + +### Actions +- `fetchAllSalesforceCrmData()` - Fetch all data types in parallel +- `fetchSalesforceEvents()` - Fetch events with pagination +- `fetchSalesforceLeads()` - Fetch leads with pagination +- `fetchSalesforceTasks()` - Fetch tasks with pagination +- `fetchSalesforceAccounts()` - Fetch accounts with pagination +- `fetchSalesforceOpportunities()` - Fetch opportunities with pagination +- `fetchSalesforceCrmCounts()` - Fetch total counts for all types +- `resetEventsPagination()` - Reset events pagination +- `resetLeadsPagination()` - Reset leads pagination +- `resetTasksPagination()` - Reset tasks pagination +- `resetAccountsPagination()` - Reset accounts pagination +- `resetOpportunitiesPagination()` - Reset opportunities pagination +- `clearData()` - Clear all data +- `clearErrors()` - Clear all errors + +### Selectors +- `selectSalesforceEvents` - Get events array +- `selectSalesforceLeads` - Get leads array +- `selectSalesforceTasks` - Get tasks array +- `selectSalesforceAccounts` - Get accounts array +- `selectSalesforceOpportunities` - Get opportunities array +- `selectSalesforceCrmLoading` - Get loading states +- `selectSalesforceCrmErrors` - Get error states +- `selectSalesforceCrmCounts` - Get total counts +- `selectDashboardData` - Get computed dashboard data with summary +- `selectIsAnyLoading` - Check if any data is loading +- `selectHasAnyError` - Check if any errors exist + +## Navigation + +The module is integrated into the CRM navigator: + +```typescript + + +``` + +## Usage + +### Navigate to Salesforce Dashboard +```typescript +navigation.navigate('SalesforceDashboard'); +``` + +### Navigate to Salesforce Data Screen +```typescript +navigation.navigate('SalesforceData'); +``` + +### Fetch Salesforce Data +```typescript +import { useDispatch } from 'react-redux'; +import { fetchAllSalesforceCrmData } from '@/modules/crm/salesforce'; + +const dispatch = useDispatch(); + +// Fetch all data +await dispatch(fetchAllSalesforceCrmData()).unwrap(); + +// Fetch specific data type with pagination +await dispatch(fetchSalesforceLeads({ page: 1, limit: 20 })).unwrap(); +``` + +### Access Salesforce Data +```typescript +import { useSelector } from 'react-redux'; +import { selectSalesforceLeads, selectDashboardData } from '@/modules/crm/salesforce'; + +// Get leads +const leads = useSelector(selectSalesforceLeads); + +// Get dashboard data with computed summaries +const dashboardData = useSelector(selectDashboardData); +``` + +## API Response Format + +All endpoints return responses in the following format: + +```json +{ + "status": "success", + "message": "Salesforce crm {resource} data fetched successfully", + "data": { + "success": true, + "data": [ /* array of records */ ], + "count": 56, + "metadata": { + "totalSize": 56, + "done": true, + "nextRecordsUrl": null + } + }, + "timestamp": "2025-10-09T06:40:31.428Z" +} +``` + +## Styling + +The module follows the application's design system: +- Uses theme colors from `useTheme()` hook +- Consistent spacing and typography +- Responsive layouts +- Material Community Icons +- Smooth animations and transitions + +## Data Persistence + +The Salesforce CRM state is persisted using Redux Persist: +- State is saved to AsyncStorage +- Survives app restarts +- Automatically rehydrated on app launch + +## Error Handling + +- All API calls wrapped in try-catch blocks +- Error states stored in Redux +- User-friendly error messages via Toast notifications +- Retry functionality on error screens +- Loading states for all async operations + +## Performance Optimizations + +- **Parallel Data Fetching**: All data types fetched simultaneously +- **Infinite Scrolling**: Load data in chunks to reduce initial load time +- **Memoized Selectors**: Prevent unnecessary re-renders +- **Redux Persist**: Cache data locally to reduce API calls +- **Pull-to-refresh**: Manual refresh when needed +- **Optimized Re-renders**: Use React.memo where appropriate + +## Future Enhancements + +- Advanced filtering options +- Search functionality +- Sort options for each data type +- Export data to CSV/Excel +- Detailed view screens for each record +- Create/Edit/Delete operations +- Real-time sync with Salesforce +- Offline mode support +- Analytics and reporting +- Custom dashboard widgets + +## Related Modules + +- **Zoho CRM** (`src/modules/crm/zoho/`) - Similar structure for Zoho integration +- **CRM Navigator** (`src/modules/crm/navigation/CrmNavigator.tsx`) - Navigation configuration +- **Root Store** (`src/store/store.ts`) - Redux store configuration + diff --git a/src/modules/crm/salesforce/components/SalesforceDataCards.tsx b/src/modules/crm/salesforce/components/SalesforceDataCards.tsx new file mode 100644 index 0000000..4ea4fdd --- /dev/null +++ b/src/modules/crm/salesforce/components/SalesforceDataCards.tsx @@ -0,0 +1,453 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import { useTheme } from '@/shared/styles/useTheme'; +import type { SalesforceEvent, SalesforceLead, SalesforceTask, SalesforceAccount, SalesforceOpportunity } from '../types/SalesforceCrmTypes'; + +interface BaseCardProps { + onPress: () => void; +} + +interface EventCardProps extends BaseCardProps { + event: SalesforceEvent; +} + +interface LeadCardProps extends BaseCardProps { + lead: SalesforceLead; +} + +interface TaskCardProps extends BaseCardProps { + task: SalesforceTask; +} + +interface AccountCardProps extends BaseCardProps { + account: SalesforceAccount; +} + +interface OpportunityCardProps extends BaseCardProps { + opportunity: SalesforceOpportunity; +} + +// Helper function to get status-based colors +const getStatusColor = (status: string, colors: any) => { + switch (status.toLowerCase()) { + case 'new': + case 'open': + case 'prospecting': + return '#3AA0FF'; + case 'working': + case 'in progress': + case 'qualification': + case 'needs analysis': + return '#F59E0B'; + case 'qualified': + case 'completed': + case 'closed won': + return '#22C55E'; + case 'unqualified': + case 'closed lost': + return '#EF4444'; + default: + return colors.textLight; + } +}; + +// Helper function to get priority-based colors +const getPriorityColor = (priority: string) => { + switch (priority.toLowerCase()) { + case 'high': + return '#EF4444'; + case 'normal': + return '#F59E0B'; + case 'low': + return '#10B981'; + default: + return '#6B7280'; + } +}; + +export const EventCard: React.FC = ({ event, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {event.Subject} + + {event.IsAllDayEvent && ( + + + All Day + + + )} + + {event.Location && ( + + {event.Location} + + )} + + + + {event.StartDateTime && ( + + + + Start: {new Date(event.StartDateTime).toLocaleString()} + + + )} + {event.EndDateTime && ( + + + + End: {new Date(event.EndDateTime).toLocaleString()} + + + )} + {event.Description && ( + + + + {event.Description} + + + )} + + + + + Created: {new Date(event.CreatedDate).toLocaleDateString()} + + + + ); +}; + +export const LeadCard: React.FC = ({ lead, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {lead.FirstName} {lead.LastName} + + + + {lead.Status} + + + + + {lead.Company} + + + + + + + + {lead.Email} + + + {lead.Phone && ( + + + + {lead.Phone} + + + )} + {lead.Title && ( + + + + {lead.Title} + + + )} + {lead.LeadSource && ( + + + + Source: {lead.LeadSource} + + + )} + + + + + Created: {lead.CreatedDate ? new Date(lead.CreatedDate).toLocaleDateString() : 'N/A'} + + + + ); +}; + +export const TaskCard: React.FC = ({ task, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {task.Subject} + + + + {task.Status} + + + + + + + {task.Description && ( + + {task.Description} + + )} + + + + Priority: {task.Priority} + + + {task.ActivityDate && ( + + + + Due: {new Date(task.ActivityDate).toLocaleDateString()} + + + )} + + + + + Created: {new Date(task.CreatedDate).toLocaleDateString()} + + + + ); +}; + +export const AccountCard: React.FC = ({ account, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {account.Name} + + {account.Type && ( + + + {account.Type} + + + )} + + {account.Industry && ( + + {account.Industry} + + )} + + + + {account.Phone && ( + + + + {account.Phone} + + + )} + {account.Website && ( + + + + {account.Website} + + + )} + {(account.BillingCity || account.BillingState) && ( + + + + {[account.BillingCity, account.BillingState, account.BillingCountry].filter(Boolean).join(', ')} + + + )} + {account.AnnualRevenue && ( + + + + Revenue: ${account.AnnualRevenue.toLocaleString()} + + + )} + + + + + Created: {new Date(account.CreatedDate).toLocaleDateString()} + + + + ); +}; + +export const OpportunityCard: React.FC = ({ opportunity, onPress }) => { + const { colors, fonts, shadows } = useTheme(); + + return ( + + + + + {opportunity.Name} + + + + {opportunity.StageName} + + + + {opportunity.Type && ( + + {opportunity.Type} + + )} + + + + + + + ${opportunity.Amount?.toLocaleString()} + + + + + + Probability: {opportunity.Probability}% + + + + + + Close date: {new Date(opportunity.CloseDate).toLocaleDateString()} + + + {opportunity.ForecastCategory && ( + + + + Forecast: {opportunity.ForecastCategory} + + + )} + + + + + Created: {new Date(opportunity.CreatedDate).toLocaleDateString()} + + + + ); +}; + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, + borderWidth: 1, + marginBottom: 16, + overflow: 'hidden', + }, + cardHeader: { + padding: 16, + paddingBottom: 12, + }, + cardTitleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 4, + }, + cardTitle: { + fontSize: 16, + flex: 1, + marginRight: 8, + }, + cardSubtitle: { + fontSize: 14, + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + }, + statusText: { + fontSize: 12, + }, + cardContent: { + paddingHorizontal: 16, + paddingBottom: 12, + }, + infoRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 6, + }, + infoText: { + marginLeft: 8, + fontSize: 14, + flex: 1, + }, + descriptionText: { + fontSize: 14, + marginBottom: 8, + lineHeight: 20, + }, + cardFooter: { + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#F8F9FA', + }, + dateText: { + fontSize: 12, + }, +}); + diff --git a/src/modules/crm/salesforce/index.ts b/src/modules/crm/salesforce/index.ts new file mode 100644 index 0000000..535d643 --- /dev/null +++ b/src/modules/crm/salesforce/index.ts @@ -0,0 +1,8 @@ +// Salesforce CRM Module Exports +export { default as SalesforceCrmDashboardScreen } from './screens/SalesforceCrmDashboardScreen'; +export { default as SalesforceDataScreen } from './screens/SalesforceDataScreen'; +export { default as salesforceCrmSlice } from './store/salesforceCrmSlice'; +export { selectDashboardData, selectIsAnyLoading, selectHasAnyError, selectSalesforceCrmCounts } from './store/selectors'; +export * from './types/SalesforceCrmTypes'; +export { salesforceCrmAPI } from './services/salesforceCrmAPI'; + diff --git a/src/modules/crm/salesforce/screens/SalesforceCrmDashboardScreen.tsx b/src/modules/crm/salesforce/screens/SalesforceCrmDashboardScreen.tsx new file mode 100644 index 0000000..7122b5e --- /dev/null +++ b/src/modules/crm/salesforce/screens/SalesforceCrmDashboardScreen.tsx @@ -0,0 +1,468 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, RefreshControl, ScrollView, ActivityIndicator } from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import { useSelector, useDispatch } from 'react-redux'; +import { useTheme } from '@/shared/styles/useTheme'; +import { useNavigation } from '@react-navigation/native'; +import { fetchAllSalesforceCrmData, fetchSalesforceCrmCounts } from '../store/salesforceCrmSlice'; +import { + selectDashboardData, + selectIsAnyLoading, + selectHasAnyError +} from '../store/selectors'; +import type { AppDispatch } from '@/store/store'; +import { PieChart, DonutChart, StackedBarChart } from '@/shared/components/charts'; + +const SalesforceCrmDashboardScreen: React.FC = () => { + const { colors, fonts } = useTheme(); + const navigation = useNavigation(); + const dispatch = useDispatch(); + + // Redux selectors + const dashboardData = useSelector(selectDashboardData); + const isLoading = useSelector(selectIsAnyLoading); + const hasError = useSelector(selectHasAnyError); + const [refreshing, setRefreshing] = useState(false); + + // Fetch data on component mount + useEffect(() => { + fetchData(); + }, [dispatch]); + + // Fetch all Salesforce CRM data + const fetchData = async () => { + try { + await dispatch(fetchAllSalesforceCrmData()).unwrap(); + await dispatch(fetchSalesforceCrmCounts()).unwrap(); + } catch (error) { + console.error('Failed to fetch Salesforce CRM data:', error); + } + }; + + // Handle refresh + const handleRefresh = async () => { + setRefreshing(true); + await fetchData(); + setRefreshing(false); + }; + + return ( + + } + > + + Salesforce CRM + navigation.navigate('SalesforceData' as never)} + > + + + View Data + + + + + {/* Loading State */} + {isLoading && !dashboardData.events.length && ( + + + + Loading Salesforce CRM data... + + + )} + + {/* Error State */} + {hasError && !dashboardData.events.length && ( + + + + Failed to load data. Pull to refresh. + + + )} + + {/* Hero Stats - Large Cards */} + + + + + Leads + + + {dashboardData.counts?.leads || dashboardData.summary.totalLeads} + + + {dashboardData.summary.newLeads} New ยท {dashboardData.summary.qualifiedLeads} Qualified + + + + + + + Opportunities + + + {dashboardData.counts?.opportunities || dashboardData.summary.totalOpportunities} + + + {dashboardData.summary.openOpportunities} Open ยท {dashboardData.summary.wonOpportunities} Won + + + + + {/* Pipeline Value Card */} + + + + Pipeline Overview + + + + Total Pipeline + + ${(dashboardData.summary.totalPipelineValue / 1000).toFixed(1)}K + + + + Avg Deal Size + + ${(dashboardData.summary.averageDealSize / 1000).toFixed(1)}K + + + + Open Deals + + {dashboardData.summary.openOpportunities} + + + + + + {/* Module Counts - Compact Grid */} + + + + + {dashboardData.counts?.events || dashboardData.summary.totalEvents} + + Events + + + + + + {dashboardData.counts?.tasks || dashboardData.summary.totalTasks} + + Tasks + + + + + + {dashboardData.counts?.accounts || dashboardData.summary.totalAccounts} + + Accounts + + + + {/* Lead Status Distribution */} + + Lead Status Distribution + + + item.value > 0)} + colors={colors} + fonts={fonts} + size={140} + /> + + {/* Legend */} + + {[ + { label: 'New', value: dashboardData.summary.newLeads, color: '#3AA0FF' }, + { label: 'Working', value: dashboardData.summary.workingLeads, color: '#F59E0B' }, + { label: 'Qualified', value: dashboardData.summary.qualifiedLeads, color: '#22C55E' }, + ].filter(item => item.value > 0).map((item) => ( + + + + {item.label} ({item.value}) + + + ))} + + + + + {/* Task Status */} + + Task Status + + + item.value > 0)} + colors={colors} + fonts={fonts} + height={120} + /> + + {/* Legend */} + + {[ + { label: 'Open', value: dashboardData.summary.openTasks, color: '#3AA0FF' }, + { label: 'Completed', value: dashboardData.summary.completedTasks, color: '#22C55E' }, + { label: 'High Priority', value: dashboardData.summary.highPriorityTasks, color: '#EF4444' }, + ].filter(item => item.value > 0).map((item) => ( + + + + {item.label} ({item.value}) + + + ))} + + + + + {/* Key Metrics */} + + + + + {dashboardData.summary.upcomingEvents} + + Upcoming Events + + + + + + {dashboardData.summary.wonOpportunities} + + Won Deals + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + }, + scrollContent: { + paddingBottom: 20, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + title: { + fontSize: 24, + }, + dataButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + }, + dataButtonText: { + marginLeft: 6, + fontSize: 14, + }, + loadingCard: { + borderRadius: 12, + borderWidth: 1, + padding: 20, + marginBottom: 16, + alignItems: 'center', + justifyContent: 'center', + }, + loadingText: { + marginTop: 12, + fontSize: 14, + textAlign: 'center', + }, + errorCard: { + borderRadius: 12, + borderWidth: 1, + padding: 12, + marginBottom: 16, + flexDirection: 'row', + alignItems: 'center', + }, + errorText: { + marginLeft: 8, + fontSize: 14, + flex: 1, + }, + heroStatsContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 16, + }, + heroCard: { + flex: 1, + marginHorizontal: 4, + padding: 20, + borderRadius: 16, + borderWidth: 1, + alignItems: 'center', + }, + heroCardHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + heroCardTitle: { + fontSize: 16, + marginLeft: 8, + }, + heroCardValue: { + fontSize: 32, + marginBottom: 4, + }, + heroCardSubtitle: { + fontSize: 12, + textAlign: 'center', + }, + pipelineCard: { + padding: 20, + borderRadius: 16, + borderWidth: 1, + marginBottom: 16, + }, + pipelineHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + pipelineTitle: { + fontSize: 20, + marginLeft: 12, + }, + pipelineStats: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + pipelineItem: { + flex: 1, + alignItems: 'center', + }, + pipelineLabel: { + fontSize: 12, + marginBottom: 4, + }, + pipelineValue: { + fontSize: 18, + }, + compactGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + marginBottom: 16, + }, + compactCard: { + width: '30%', + padding: 12, + borderRadius: 12, + borderWidth: 1, + alignItems: 'center', + marginBottom: 8, + }, + compactValue: { + fontSize: 18, + marginVertical: 4, + }, + compactLabel: { + fontSize: 10, + textAlign: 'center', + }, + card: { + borderRadius: 12, + borderWidth: 1, + padding: 16, + marginBottom: 16, + }, + cardTitle: { + fontSize: 16, + marginBottom: 12, + }, + chartContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 10, + }, + pieLegend: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + marginTop: 12, + gap: 8, + }, + barLegend: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + marginTop: 8, + gap: 6, + }, + legendItem: { + flexDirection: 'row', + alignItems: 'center', + marginRight: 12, + marginTop: 6, + }, + legendDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 6, + }, + legendText: { + fontSize: 12, + }, + metricsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 16, + }, + metricCard: { + flex: 1, + marginHorizontal: 4, + padding: 16, + borderRadius: 12, + borderWidth: 1, + alignItems: 'center', + }, + metricValue: { + fontSize: 20, + marginVertical: 8, + }, + metricLabel: { + fontSize: 12, + textAlign: 'center', + }, +}); + +export default SalesforceCrmDashboardScreen; + diff --git a/src/modules/crm/salesforce/screens/SalesforceDataScreen.tsx b/src/modules/crm/salesforce/screens/SalesforceDataScreen.tsx new file mode 100644 index 0000000..7859d1a --- /dev/null +++ b/src/modules/crm/salesforce/screens/SalesforceDataScreen.tsx @@ -0,0 +1,473 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + RefreshControl, + FlatList, + ActivityIndicator, +} from 'react-native'; +import { useSelector, useDispatch } from 'react-redux'; +import type { AppDispatch } from '@/store/store'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import { LoadingSpinner, ErrorState } from '@/shared/components/ui'; +import { useTheme } from '@/shared/styles/useTheme'; +import { showError, showSuccess, showInfo } from '@/shared/utils/Toast'; +import { EventCard, LeadCard, TaskCard, AccountCard, OpportunityCard } from '../components/SalesforceDataCards'; +import { + selectSalesforceEvents, + selectSalesforceLeads, + selectSalesforceTasks, + selectSalesforceAccounts, + selectSalesforceOpportunities, + selectSalesforceCrmLoading, + selectSalesforceCrmErrors, + selectEventsPagination, + selectLeadsPagination, + selectTasksPagination, + selectAccountsPagination, + selectOpportunitiesPagination, + selectSalesforceCrmCounts +} from '../store/selectors'; +import { + fetchAllSalesforceCrmData, + fetchSalesforceEvents, + fetchSalesforceLeads, + fetchSalesforceTasks, + fetchSalesforceAccounts, + fetchSalesforceOpportunities, + fetchSalesforceCrmCounts, + resetEventsPagination, + resetLeadsPagination, + resetTasksPagination, + resetAccountsPagination, + resetOpportunitiesPagination +} from '../store/salesforceCrmSlice'; + +const SalesforceDataScreen: React.FC = () => { + const { colors, fonts } = useTheme(); + const dispatch = useDispatch(); + const [selectedTab, setSelectedTab] = useState<'events' | 'leads' | 'tasks' | 'accounts' | 'opportunities'>('leads'); + const [refreshing, setRefreshing] = useState(false); + + // Redux selectors + const events = useSelector(selectSalesforceEvents); + const leads = useSelector(selectSalesforceLeads); + const tasks = useSelector(selectSalesforceTasks); + const accounts = useSelector(selectSalesforceAccounts); + const opportunities = useSelector(selectSalesforceOpportunities); + const loading = useSelector(selectSalesforceCrmLoading); + const errors = useSelector(selectSalesforceCrmErrors); + + // Pagination selectors + const eventsPagination = useSelector(selectEventsPagination); + const leadsPagination = useSelector(selectLeadsPagination); + const tasksPagination = useSelector(selectTasksPagination); + const accountsPagination = useSelector(selectAccountsPagination); + const opportunitiesPagination = useSelector(selectOpportunitiesPagination); + const counts = useSelector(selectSalesforceCrmCounts); + + // Fetch Salesforce CRM data + const fetchSalesforceData = useCallback(async (showRefresh = false) => { + try { + if (showRefresh) { + setRefreshing(true); + } + + // Dispatch Redux action to fetch all Salesforce CRM data + await dispatch(fetchAllSalesforceCrmData()).unwrap(); + + if (showRefresh) { + showSuccess('Salesforce data refreshed successfully'); + } + } catch (err) { + const errorMessage = 'Failed to fetch Salesforce data'; + showError(errorMessage); + } finally { + setRefreshing(false); + } + }, [dispatch]); + + // Load more data for infinite scrolling + const loadMoreData = useCallback(async (dataType: string) => { + try { + let pagination; + let fetchAction; + + switch (dataType) { + case 'events': + pagination = eventsPagination; + fetchAction = fetchSalesforceEvents; + break; + case 'leads': + pagination = leadsPagination; + fetchAction = fetchSalesforceLeads; + break; + case 'tasks': + pagination = tasksPagination; + fetchAction = fetchSalesforceTasks; + break; + case 'accounts': + pagination = accountsPagination; + fetchAction = fetchSalesforceAccounts; + break; + case 'opportunities': + pagination = opportunitiesPagination; + fetchAction = fetchSalesforceOpportunities; + break; + default: + return; + } + + // Check if there are more records and not currently loading + if (!pagination.moreRecords || loading[dataType as keyof typeof loading]) { + return; + } + + // Fetch next page + await (dispatch(fetchAction({ + page: pagination.page + 1, + limit: 20, + append: true + }) as any)).unwrap(); + + } catch (err) { + showError(`Failed to load more ${dataType}`); + } + }, [dispatch, eventsPagination, leadsPagination, tasksPagination, accountsPagination, opportunitiesPagination, loading]); + + useEffect(() => { + fetchSalesforceData(); + // Fetch counts in parallel + dispatch(fetchSalesforceCrmCounts()); + }, []); + + const handleRefresh = useCallback(() => { + // Reset pagination for all data types before refreshing + dispatch(resetEventsPagination()); + dispatch(resetLeadsPagination()); + dispatch(resetTasksPagination()); + dispatch(resetAccountsPagination()); + dispatch(resetOpportunitiesPagination()); + + // Then fetch fresh data and counts + fetchSalesforceData(true); + dispatch(fetchSalesforceCrmCounts()); + }, [fetchSalesforceData, dispatch]); + + const handleRetry = useCallback(() => { + fetchSalesforceData(); + }, [fetchSalesforceData]); + + const handleCardPress = useCallback((item: any, type: string) => { + showInfo(`Viewing ${type}: ${item.Name || item.Subject || `${item.FirstName} ${item.LastName}`}`); + }, []); + + // Render loading footer for infinite scroll + const renderFooter = useCallback((dataType: string) => { + const isLoadingMore = loading[dataType as keyof typeof loading]; + if (!isLoadingMore) return null; + + return ( + + + + Loading more... + + + ); + }, [loading, colors]); + + // Get current loading state and error + const isLoading = loading.events || loading.leads || loading.tasks || loading.accounts || loading.opportunities; + const hasError = errors.events || errors.leads || errors.tasks || errors.accounts || errors.opportunities; + + // Tab configuration with counts from API + const tabs = [ + { + key: 'events', + label: 'Events', + icon: 'calendar', + count: counts?.events || events.length + }, + { + key: 'leads', + label: 'Leads', + icon: 'account-heart', + count: counts?.leads || leads.length + }, + { + key: 'tasks', + label: 'Tasks', + icon: 'check-circle', + count: counts?.tasks || tasks.length + }, + { + key: 'accounts', + label: 'Accounts', + icon: 'domain', + count: counts?.accounts || accounts.length + }, + { + key: 'opportunities', + label: 'Opportunities', + icon: 'handshake', + count: counts?.opportunities || opportunities.length + }, + ] as const; + + const renderTabContent = useCallback(() => { + const commonFlatListProps = { + numColumns: 1, + showsVerticalScrollIndicator: false, + contentContainerStyle: styles.listContainer, + refreshControl: ( + + ), + }; + + switch (selectedTab) { + case 'events': + return ( + ( + handleCardPress(item, 'Event')} + /> + )} + keyExtractor={(item) => `event-${item.Id}`} + onEndReached={() => loadMoreData('events')} + onEndReachedThreshold={0.1} + ListFooterComponent={() => renderFooter('events')} + {...commonFlatListProps} + /> + ); + case 'leads': + return ( + ( + handleCardPress(item, 'Lead')} + /> + )} + keyExtractor={(item) => `lead-${item.Id}`} + onEndReached={() => loadMoreData('leads')} + onEndReachedThreshold={0.1} + ListFooterComponent={() => renderFooter('leads')} + {...commonFlatListProps} + /> + ); + case 'tasks': + return ( + ( + handleCardPress(item, 'Task')} + /> + )} + keyExtractor={(item) => `task-${item.Id}`} + onEndReached={() => loadMoreData('tasks')} + onEndReachedThreshold={0.1} + ListFooterComponent={() => renderFooter('tasks')} + {...commonFlatListProps} + /> + ); + case 'accounts': + return ( + ( + handleCardPress(item, 'Account')} + /> + )} + keyExtractor={(item) => `account-${item.Id}`} + onEndReached={() => loadMoreData('accounts')} + onEndReachedThreshold={0.1} + ListFooterComponent={() => renderFooter('accounts')} + {...commonFlatListProps} + /> + ); + case 'opportunities': + return ( + ( + handleCardPress(item, 'Opportunity')} + /> + )} + keyExtractor={(item) => `opportunity-${item.Id}`} + onEndReached={() => loadMoreData('opportunities')} + onEndReachedThreshold={0.1} + ListFooterComponent={() => renderFooter('opportunities')} + {...commonFlatListProps} + /> + ); + default: + return null; + } + }, [selectedTab, events, leads, tasks, accounts, opportunities, handleCardPress, loadMoreData, renderFooter, refreshing, handleRefresh]); + + // Conditional returns after all hooks + if (isLoading && !leads.length) { + return ; + } + + if (hasError && !leads.length) { + return ; + } + + return ( + + {/* Fixed Header */} + + + Salesforce CRM Data + + + + + + + {/* Fixed Tabs */} + + + + {tabs.map((tab) => ( + setSelectedTab(tab.key)} + activeOpacity={0.8} + > + + + {tab.label} + + + + {tab.count} + + + + ))} + + + + + {/* Scrollable Content */} + + {renderTabContent()} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + }, + title: { + fontSize: 24, + }, + tabsContainer: { + backgroundColor: '#FFFFFF', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + tabs: { + flexDirection: 'row', + paddingHorizontal: 16, + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + marginRight: 8, + borderRadius: 20, + backgroundColor: '#F1F5F9', + }, + tabText: { + marginLeft: 6, + fontSize: 14, + }, + countBadge: { + marginLeft: 6, + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + minWidth: 20, + alignItems: 'center', + }, + countText: { + fontSize: 12, + }, + content: { + flex: 1, + }, + listContainer: { + paddingHorizontal: 16, + paddingBottom: 20, + }, + footerLoader: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 16, + }, + footerText: { + marginLeft: 8, + fontSize: 14, + }, +}); + +export default SalesforceDataScreen; + diff --git a/src/modules/crm/salesforce/services/salesforceCrmAPI.ts b/src/modules/crm/salesforce/services/salesforceCrmAPI.ts new file mode 100644 index 0000000..bff424a --- /dev/null +++ b/src/modules/crm/salesforce/services/salesforceCrmAPI.ts @@ -0,0 +1,65 @@ +import http from '@/services/http'; +import type { + SalesforceEvent, + SalesforceLead, + SalesforceTask, + SalesforceAccount, + SalesforceOpportunity, + SalesforceSearchParams, + SalesforceApiResponse +} from '../types/SalesforceCrmTypes'; + +// Available Salesforce CRM resource types +export type SalesforceResourceType = 'events' | 'leads' | 'tasks' | 'accounts' | 'opportunities'; + +// Base API endpoint for Salesforce CRM +const SALESFORCE_CRM_BASE_URL = '/api/v1/n8n/salesforce/crm'; + +export const salesforceCrmAPI = { + // Generic method to get Salesforce CRM data by resource type + getSalesforceCrmData: ( + resource: SalesforceResourceType, + params?: SalesforceSearchParams + ) => { + const queryParams = { + page: params?.page || 1, + limit: params?.limit || 20, + ...params + }; + + return http.get>(`${SALESFORCE_CRM_BASE_URL}/${resource}`, queryParams); + }, + + // Specific resource methods for type safety + getEvents: (params?: SalesforceSearchParams) => + salesforceCrmAPI.getSalesforceCrmData('events', params), + + getLeads: (params?: SalesforceSearchParams) => + salesforceCrmAPI.getSalesforceCrmData('leads', params), + + getTasks: (params?: SalesforceSearchParams) => + salesforceCrmAPI.getSalesforceCrmData('tasks', params), + + getAccounts: (params?: SalesforceSearchParams) => + salesforceCrmAPI.getSalesforceCrmData('accounts', params), + + getOpportunities: (params?: SalesforceSearchParams) => + salesforceCrmAPI.getSalesforceCrmData('opportunities', params), + + // Get counts for all Salesforce CRM modules + getSalesforceCrmCounts: () => { + return http.get<{ + status: string; + message: string; + data: { + events: { count: number; success: boolean }; + leads: { count: number; success: boolean }; + tasks: { count: number; success: boolean }; + accounts: { count: number; success: boolean }; + opportunities: { count: number; success: boolean }; + }; + timestamp: string; + }>(`${SALESFORCE_CRM_BASE_URL}/counts`); + }, +}; + diff --git a/src/modules/crm/salesforce/store/salesforceCrmSlice.ts b/src/modules/crm/salesforce/store/salesforceCrmSlice.ts new file mode 100644 index 0000000..d80a2ec --- /dev/null +++ b/src/modules/crm/salesforce/store/salesforceCrmSlice.ts @@ -0,0 +1,517 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import { salesforceCrmAPI } from '../services/salesforceCrmAPI'; +import type { + SalesforceEvent, + SalesforceLead, + SalesforceTask, + SalesforceAccount, + SalesforceOpportunity, + SalesforceStats, + SalesforceSearchParams +} from '../types/SalesforceCrmTypes'; + +// State interface +export interface SalesforceCrmState { + // Data + events: SalesforceEvent[]; + leads: SalesforceLead[]; + tasks: SalesforceTask[]; + accounts: SalesforceAccount[]; + opportunities: SalesforceOpportunity[]; + + // Loading states + loading: { + events: boolean; + leads: boolean; + tasks: boolean; + accounts: boolean; + opportunities: boolean; + stats: boolean; + }; + + // Error states + errors: { + events: string | null; + leads: string | null; + tasks: string | null; + accounts: string | null; + opportunities: string | null; + stats: string | null; + }; + + // Pagination + pagination: { + events: { page: number; count: number; moreRecords: boolean }; + leads: { page: number; count: number; moreRecords: boolean }; + tasks: { page: number; count: number; moreRecords: boolean }; + accounts: { page: number; count: number; moreRecords: boolean }; + opportunities: { page: number; count: number; moreRecords: boolean }; + }; + + // Statistics + stats: SalesforceStats | null; + + // Module counts + counts: { + events: number; + leads: number; + tasks: number; + accounts: number; + opportunities: number; + } | null; + + // Last updated timestamps + lastUpdated: { + events: string | null; + leads: string | null; + tasks: string | null; + accounts: string | null; + opportunities: string | null; + stats: string | null; + }; +} + +// Initial state +const initialState: SalesforceCrmState = { + events: [], + leads: [], + tasks: [], + accounts: [], + opportunities: [], + loading: { + events: false, + leads: false, + tasks: false, + accounts: false, + opportunities: false, + stats: false, + }, + errors: { + events: null, + leads: null, + tasks: null, + accounts: null, + opportunities: null, + stats: null, + }, + pagination: { + events: { page: 1, count: 0, moreRecords: false }, + leads: { page: 1, count: 0, moreRecords: false }, + tasks: { page: 1, count: 0, moreRecords: false }, + accounts: { page: 1, count: 0, moreRecords: false }, + opportunities: { page: 1, count: 0, moreRecords: false }, + }, + stats: null, + counts: null, + lastUpdated: { + events: null, + leads: null, + tasks: null, + accounts: null, + opportunities: null, + stats: null, + }, +}; + +// Async thunks for infinite scrolling +export const fetchSalesforceEvents = createAsyncThunk( + 'salesforceCrm/fetchEvents', + async (params?: SalesforceSearchParams & { append?: boolean }) => { + const response = await salesforceCrmAPI.getEvents(params); + return { + data: response.data?.data?.data || [], + count: response.data?.data?.count || 0, + moreRecords: !response.data?.data?.metadata?.done, + append: params?.append || false + }; + } +); + +export const fetchSalesforceLeads = createAsyncThunk( + 'salesforceCrm/fetchLeads', + async (params?: SalesforceSearchParams & { append?: boolean }) => { + const response = await salesforceCrmAPI.getLeads(params); + return { + data: response.data?.data?.data || [], + count: response.data?.data?.count || 0, + moreRecords: !response.data?.data?.metadata?.done, + append: params?.append || false + }; + } +); + +export const fetchSalesforceTasks = createAsyncThunk( + 'salesforceCrm/fetchTasks', + async (params?: SalesforceSearchParams & { append?: boolean }) => { + const response = await salesforceCrmAPI.getTasks(params); + return { + data: response.data?.data?.data || [], + count: response.data?.data?.count || 0, + moreRecords: !response.data?.data?.metadata?.done, + append: params?.append || false + }; + } +); + +export const fetchSalesforceAccounts = createAsyncThunk( + 'salesforceCrm/fetchAccounts', + async (params?: SalesforceSearchParams & { append?: boolean }) => { + const response = await salesforceCrmAPI.getAccounts(params); + return { + data: response.data?.data?.data || [], + count: response.data?.data?.count || 0, + moreRecords: !response.data?.data?.metadata?.done, + append: params?.append || false + }; + } +); + +export const fetchSalesforceOpportunities = createAsyncThunk( + 'salesforceCrm/fetchOpportunities', + async (params?: SalesforceSearchParams & { append?: boolean }) => { + const response = await salesforceCrmAPI.getOpportunities(params); + return { + data: response.data?.data?.data || [], + count: response.data?.data?.count || 0, + moreRecords: !response.data?.data?.metadata?.done, + append: params?.append || false + }; + } +); + +// Fetch Salesforce CRM counts +export const fetchSalesforceCrmCounts = createAsyncThunk( + 'salesforceCrm/fetchCounts', + async () => { + const response = await salesforceCrmAPI.getSalesforceCrmCounts(); + return response.data || { + events: { count: 0, success: false }, + leads: { count: 0, success: false }, + tasks: { count: 0, success: false }, + accounts: { count: 0, success: false }, + opportunities: { count: 0, success: false }, + }; + } +); + +// Fetch all Salesforce CRM data +export const fetchAllSalesforceCrmData = createAsyncThunk( + 'salesforceCrm/fetchAllData', + async (params?: SalesforceSearchParams) => { + const [ + eventsResponse, + leadsResponse, + tasksResponse, + accountsResponse, + opportunitiesResponse + ] = await Promise.all([ + salesforceCrmAPI.getEvents(params), + salesforceCrmAPI.getLeads(params), + salesforceCrmAPI.getTasks(params), + salesforceCrmAPI.getAccounts(params), + salesforceCrmAPI.getOpportunities(params), + ]); + + return { + events: { + data: eventsResponse.data?.data?.data || [], + count: eventsResponse.data?.data?.count || 0, + moreRecords: !eventsResponse.data?.data?.metadata?.done + }, + leads: { + data: leadsResponse.data?.data?.data || [], + count: leadsResponse.data?.data?.count || 0, + moreRecords: !leadsResponse.data?.data?.metadata?.done + }, + tasks: { + data: tasksResponse.data?.data?.data || [], + count: tasksResponse.data?.data?.count || 0, + moreRecords: !tasksResponse.data?.data?.metadata?.done + }, + accounts: { + data: accountsResponse.data?.data?.data || [], + count: accountsResponse.data?.data?.count || 0, + moreRecords: !accountsResponse.data?.data?.metadata?.done + }, + opportunities: { + data: opportunitiesResponse.data?.data?.data || [], + count: opportunitiesResponse.data?.data?.count || 0, + moreRecords: !opportunitiesResponse.data?.data?.metadata?.done + }, + }; + } +); + +// Slice +const salesforceCrmSlice = createSlice({ + name: 'salesforceCrm', + initialState, + reducers: { + clearErrors: (state) => { + state.errors = { + events: null, + leads: null, + tasks: null, + accounts: null, + opportunities: null, + stats: null, + }; + }, + clearData: (state) => { + state.events = []; + state.leads = []; + state.tasks = []; + state.accounts = []; + state.opportunities = []; + state.stats = null; + }, + // Reset pagination for specific data types + resetEventsPagination: (state) => { + state.pagination.events = { page: 1, count: 0, moreRecords: false }; + state.events = []; + }, + resetLeadsPagination: (state) => { + state.pagination.leads = { page: 1, count: 0, moreRecords: false }; + state.leads = []; + }, + resetTasksPagination: (state) => { + state.pagination.tasks = { page: 1, count: 0, moreRecords: false }; + state.tasks = []; + }, + resetAccountsPagination: (state) => { + state.pagination.accounts = { page: 1, count: 0, moreRecords: false }; + state.accounts = []; + }, + resetOpportunitiesPagination: (state) => { + state.pagination.opportunities = { page: 1, count: 0, moreRecords: false }; + state.opportunities = []; + }, + }, + extraReducers: (builder) => { + // Fetch events + builder + .addCase(fetchSalesforceEvents.pending, (state) => { + state.loading.events = true; + state.errors.events = null; + }) + .addCase(fetchSalesforceEvents.fulfilled, (state, action) => { + state.loading.events = false; + const { data, count, moreRecords, append } = action.payload; + + if (append) { + // Append new data for infinite scrolling + state.events = [...state.events, ...data]; + } else { + // Replace data for initial load or refresh + state.events = data; + } + + state.pagination.events = { + page: append ? state.pagination.events.page + 1 : 1, + count, + moreRecords + }; + state.lastUpdated.events = new Date().toISOString(); + }) + .addCase(fetchSalesforceEvents.rejected, (state, action) => { + state.loading.events = false; + state.errors.events = action.error.message || 'Failed to fetch events'; + }) + + // Fetch leads + .addCase(fetchSalesforceLeads.pending, (state) => { + state.loading.leads = true; + state.errors.leads = null; + }) + .addCase(fetchSalesforceLeads.fulfilled, (state, action) => { + state.loading.leads = false; + const { data, count, moreRecords, append } = action.payload; + + if (append) { + state.leads = [...state.leads, ...data]; + } else { + state.leads = data; + } + + state.pagination.leads = { + page: append ? state.pagination.leads.page + 1 : 1, + count, + moreRecords + }; + state.lastUpdated.leads = new Date().toISOString(); + }) + .addCase(fetchSalesforceLeads.rejected, (state, action) => { + state.loading.leads = false; + state.errors.leads = action.error.message || 'Failed to fetch leads'; + }) + + // Fetch tasks + .addCase(fetchSalesforceTasks.pending, (state) => { + state.loading.tasks = true; + state.errors.tasks = null; + }) + .addCase(fetchSalesforceTasks.fulfilled, (state, action) => { + state.loading.tasks = false; + const { data, count, moreRecords, append } = action.payload; + + if (append) { + state.tasks = [...state.tasks, ...data]; + } else { + state.tasks = data; + } + + state.pagination.tasks = { + page: append ? state.pagination.tasks.page + 1 : 1, + count, + moreRecords + }; + state.lastUpdated.tasks = new Date().toISOString(); + }) + .addCase(fetchSalesforceTasks.rejected, (state, action) => { + state.loading.tasks = false; + state.errors.tasks = action.error.message || 'Failed to fetch tasks'; + }) + + // Fetch accounts + .addCase(fetchSalesforceAccounts.pending, (state) => { + state.loading.accounts = true; + state.errors.accounts = null; + }) + .addCase(fetchSalesforceAccounts.fulfilled, (state, action) => { + state.loading.accounts = false; + const { data, count, moreRecords, append } = action.payload; + + if (append) { + state.accounts = [...state.accounts, ...data]; + } else { + state.accounts = data; + } + + state.pagination.accounts = { + page: append ? state.pagination.accounts.page + 1 : 1, + count, + moreRecords + }; + state.lastUpdated.accounts = new Date().toISOString(); + }) + .addCase(fetchSalesforceAccounts.rejected, (state, action) => { + state.loading.accounts = false; + state.errors.accounts = action.error.message || 'Failed to fetch accounts'; + }) + + // Fetch opportunities + .addCase(fetchSalesforceOpportunities.pending, (state) => { + state.loading.opportunities = true; + state.errors.opportunities = null; + }) + .addCase(fetchSalesforceOpportunities.fulfilled, (state, action) => { + state.loading.opportunities = false; + const { data, count, moreRecords, append } = action.payload; + + if (append) { + state.opportunities = [...state.opportunities, ...data]; + } else { + state.opportunities = data; + } + + state.pagination.opportunities = { + page: append ? state.pagination.opportunities.page + 1 : 1, + count, + moreRecords + }; + state.lastUpdated.opportunities = new Date().toISOString(); + }) + .addCase(fetchSalesforceOpportunities.rejected, (state, action) => { + state.loading.opportunities = false; + state.errors.opportunities = action.error.message || 'Failed to fetch opportunities'; + }) + + // Fetch all Salesforce CRM data + .addCase(fetchAllSalesforceCrmData.pending, (state) => { + state.loading.events = true; + state.loading.leads = true; + state.loading.tasks = true; + state.loading.accounts = true; + state.loading.opportunities = true; + state.errors.events = null; + state.errors.leads = null; + state.errors.tasks = null; + state.errors.accounts = null; + state.errors.opportunities = null; + }) + .addCase(fetchAllSalesforceCrmData.fulfilled, (state, action) => { + const { events, leads, tasks, accounts, opportunities } = action.payload; + + state.loading.events = false; + state.loading.leads = false; + state.loading.tasks = false; + state.loading.accounts = false; + state.loading.opportunities = false; + + state.events = events.data; + state.leads = leads.data; + state.tasks = tasks.data; + state.accounts = accounts.data; + state.opportunities = opportunities.data; + + state.pagination.events = { page: 1, count: events.count, moreRecords: events.moreRecords }; + state.pagination.leads = { page: 1, count: leads.count, moreRecords: leads.moreRecords }; + state.pagination.tasks = { page: 1, count: tasks.count, moreRecords: tasks.moreRecords }; + state.pagination.accounts = { page: 1, count: accounts.count, moreRecords: accounts.moreRecords }; + state.pagination.opportunities = { page: 1, count: opportunities.count, moreRecords: opportunities.moreRecords }; + + const now = new Date().toISOString(); + state.lastUpdated.events = now; + state.lastUpdated.leads = now; + state.lastUpdated.tasks = now; + state.lastUpdated.accounts = now; + state.lastUpdated.opportunities = now; + }) + .addCase(fetchAllSalesforceCrmData.rejected, (state, action) => { + state.loading.events = false; + state.loading.leads = false; + state.loading.tasks = false; + state.loading.accounts = false; + state.loading.opportunities = false; + + const errorMessage = action.error.message || 'Failed to fetch Salesforce CRM data'; + state.errors.events = errorMessage; + state.errors.leads = errorMessage; + state.errors.tasks = errorMessage; + state.errors.accounts = errorMessage; + state.errors.opportunities = errorMessage; + }) + + // Fetch Salesforce CRM counts + .addCase(fetchSalesforceCrmCounts.pending, (state) => { + // No loading state needed for counts as it's fetched in background + }) + .addCase(fetchSalesforceCrmCounts.fulfilled, (state, action) => { + const data = action.payload as any; + state.counts = { + events: data.events?.count || 0, + leads: data.leads?.count || 0, + tasks: data.tasks?.count || 0, + accounts: data.accounts?.count || 0, + opportunities: data.opportunities?.count || 0, + }; + }) + .addCase(fetchSalesforceCrmCounts.rejected, (state) => { + // Keep existing counts on error + console.warn('Failed to fetch Salesforce CRM counts'); + }); + }, +}); + +export const { + clearErrors, + clearData, + resetEventsPagination, + resetLeadsPagination, + resetTasksPagination, + resetAccountsPagination, + resetOpportunitiesPagination, +} = salesforceCrmSlice.actions; + +export default salesforceCrmSlice.reducer; + diff --git a/src/modules/crm/salesforce/store/selectors.ts b/src/modules/crm/salesforce/store/selectors.ts new file mode 100644 index 0000000..284c668 --- /dev/null +++ b/src/modules/crm/salesforce/store/selectors.ts @@ -0,0 +1,150 @@ +import { createSelector } from '@reduxjs/toolkit'; +import type { RootState } from '@/store/store'; +import type { SalesforceCrmState } from './salesforceCrmSlice'; + +// Base selector +const selectSalesforceCrmState = (state: RootState): SalesforceCrmState => + state.salesforceCrm as SalesforceCrmState; + +// Data selectors +export const selectSalesforceEvents = createSelector( + [selectSalesforceCrmState], + (state) => state.events +); + +export const selectSalesforceLeads = createSelector( + [selectSalesforceCrmState], + (state) => state.leads +); + +export const selectSalesforceTasks = createSelector( + [selectSalesforceCrmState], + (state) => state.tasks +); + +export const selectSalesforceAccounts = createSelector( + [selectSalesforceCrmState], + (state) => state.accounts +); + +export const selectSalesforceOpportunities = createSelector( + [selectSalesforceCrmState], + (state) => state.opportunities +); + +// Loading selectors +export const selectSalesforceCrmLoading = createSelector( + [selectSalesforceCrmState], + (state) => state.loading +); + +export const selectIsAnyLoading = createSelector( + [selectSalesforceCrmLoading], + (loading) => Object.values(loading).some(isLoading => isLoading) +); + +// Error selectors +export const selectSalesforceCrmErrors = createSelector( + [selectSalesforceCrmState], + (state) => state.errors +); + +export const selectHasAnyError = createSelector( + [selectSalesforceCrmErrors], + (errors) => Object.values(errors).some(error => error !== null) +); + +// Pagination selectors +export const selectEventsPagination = createSelector( + [selectSalesforceCrmState], + (state) => state.pagination.events +); + +export const selectLeadsPagination = createSelector( + [selectSalesforceCrmState], + (state) => state.pagination.leads +); + +export const selectTasksPagination = createSelector( + [selectSalesforceCrmState], + (state) => state.pagination.tasks +); + +export const selectAccountsPagination = createSelector( + [selectSalesforceCrmState], + (state) => state.pagination.accounts +); + +export const selectOpportunitiesPagination = createSelector( + [selectSalesforceCrmState], + (state) => state.pagination.opportunities +); + +// Count selectors +export const selectSalesforceCrmCounts = createSelector( + [selectSalesforceCrmState], + (state) => state.counts +); + +// Stats selectors +export const selectSalesforceCrmStats = createSelector( + [selectSalesforceCrmState], + (state) => state.stats +); + +// Dashboard data selector - combines all data for dashboard view +export const selectDashboardData = createSelector( + [ + selectSalesforceEvents, + selectSalesforceLeads, + selectSalesforceTasks, + selectSalesforceAccounts, + selectSalesforceOpportunities, + selectSalesforceCrmCounts, + ], + (events, leads, tasks, accounts, opportunities, counts) => ({ + events, + leads, + tasks, + accounts, + opportunities, + counts, + summary: { + totalEvents: events.length, + totalLeads: leads.length, + totalTasks: tasks.length, + totalAccounts: accounts.length, + totalOpportunities: opportunities.length, + + // Event stats + upcomingEvents: events.filter(e => e.StartDateTime && new Date(e.StartDateTime) > new Date()).length, + + // Lead stats + newLeads: leads.filter(l => l.Status === 'New').length, + workingLeads: leads.filter(l => l.Status === 'Working').length, + qualifiedLeads: leads.filter(l => l.Status === 'Qualified').length, + + // Task stats + openTasks: tasks.filter(t => t.Status === 'Open').length, + completedTasks: tasks.filter(t => t.Status === 'Completed').length, + highPriorityTasks: tasks.filter(t => t.Priority === 'High').length, + + // Opportunity stats + openOpportunities: opportunities.filter(o => !o.IsClosed).length, + totalPipelineValue: opportunities + .filter(o => !o.IsClosed) + .reduce((sum, o) => sum + (o.Amount || 0), 0), + averageDealSize: opportunities.length > 0 + ? opportunities.reduce((sum, o) => sum + (o.Amount || 0), 0) / opportunities.length + : 0, + wonOpportunities: opportunities.filter(o => o.IsWon).length, + }, + }) +); + +// Last updated selector +export const selectLastUpdated = createSelector( + [selectSalesforceCrmState], + (state) => state.lastUpdated +); + diff --git a/src/modules/crm/salesforce/types/SalesforceCrmTypes.ts b/src/modules/crm/salesforce/types/SalesforceCrmTypes.ts new file mode 100644 index 0000000..f2d16f1 --- /dev/null +++ b/src/modules/crm/salesforce/types/SalesforceCrmTypes.ts @@ -0,0 +1,273 @@ +// Salesforce CRM Data Types for Integration + +export interface SalesforceEvent { + Id: string; + Subject: string; + StartDateTime: string | null; + EndDateTime: string | null; + Location: string; + Description: string | null; + OwnerId: string; + WhatId: string | null; + WhoId: string | null; + IsAllDayEvent: boolean; + CreatedDate: string; + attributes: { + type: string; + url: string; + }; +} + +export interface SalesforceLead { + Id: string; + FirstName: string; + LastName: string; + Company: string; + Email: string; + Status: LeadStatus; + Phone?: string; + Title?: string; + Street?: string; + City?: string; + State?: string; + Country?: string; + PostalCode?: string; + Website?: string; + Industry?: string; + NumberOfEmployees?: number; + AnnualRevenue?: number; + OwnerId?: string; + LeadSource?: string; + Description?: string; + CreatedDate?: string; + LastModifiedDate?: string; + attributes: { + type: string; + url: string; + }; +} + +export interface SalesforceTask { + Id: string; + Subject: string; + Status: TaskStatus; + Priority: TaskPriority; + ActivityDate: string | null; + WhatId: string | null; + WhoId: string | null; + OwnerId: string; + Description: string | null; + CreatedDate: string; + LastModifiedDate: string; + IsClosed?: boolean; + IsHighPriority?: boolean; + attributes: { + type: string; + url: string; + }; +} + +export interface SalesforceAccount { + Id: string; + Name: string; + Industry: string | null; + Type: string | null; + Phone: string | null; + BillingCity: string | null; + BillingState: string | null; + BillingCountry: string | null; + BillingStreet?: string | null; + BillingPostalCode?: string | null; + ShippingCity?: string | null; + ShippingState?: string | null; + ShippingCountry?: string | null; + ShippingStreet?: string | null; + ShippingPostalCode?: string | null; + Website: string | null; + OwnerId: string; + CreatedDate: string; + LastModifiedDate?: string; + Description?: string | null; + NumberOfEmployees?: number; + AnnualRevenue?: number; + attributes: { + type: string; + url: string; + }; +} + +export interface SalesforceOpportunity { + Id: string; + Name: string; + StageName: OpportunityStage; + Amount: number; + CloseDate: string; + AccountId: string | null; + Type: string | null; + Probability: number; + ForecastCategory: string; + OwnerId: string; + CreatedDate: string; + LastModifiedDate: string; + Description?: string | null; + LeadSource?: string | null; + NextStep?: string | null; + IsClosed?: boolean; + IsWon?: boolean; + ExpectedRevenue?: number; + attributes: { + type: string; + url: string; + }; +} + +// Enums and Union Types +export type LeadStatus = 'New' | 'Working' | 'Nurturing' | 'Qualified' | 'Unqualified'; +export type TaskStatus = 'Open' | 'In Progress' | 'Completed' | 'Waiting' | 'Deferred'; +export type TaskPriority = 'High' | 'Normal' | 'Low'; +export type OpportunityStage = + | 'Prospecting' + | 'Qualification' + | 'Needs Analysis' + | 'Value Proposition' + | 'Identify Decision Makers' + | 'Perception Analysis' + | 'Proposal/Price Quote' + | 'Negotiation/Review' + | 'Closed Won' + | 'Closed Lost'; + +// Combined Salesforce CRM Data Interface +export interface SalesforceCrmData { + events: SalesforceEvent[]; + leads: SalesforceLead[]; + tasks: SalesforceTask[]; + accounts: SalesforceAccount[]; + opportunities: SalesforceOpportunity[]; +} + +// API Response Types +export interface SalesforceApiResponse { + status: 'success' | 'error'; + message: string; + data?: { + success: boolean; + data: T[]; + count: number; + metadata: { + totalSize: number; + done: boolean; + nextRecordsUrl: string | null; + }; + }; + error?: string; + timestamp: string; +} + +export interface SalesforcePaginatedResponse { + data: T[]; + info: { + count: number; + moreRecords: boolean; + page: number; + }; +} + +// Filter and Search Types +export interface SalesforceFilters { + events?: { + dateRange?: { + start: string; + end: string; + }; + }; + leads?: { + status?: LeadStatus[]; + source?: string[]; + assignedTo?: string[]; + dateRange?: { + start: string; + end: string; + }; + }; + tasks?: { + status?: TaskStatus[]; + priority?: TaskPriority[]; + assignedTo?: string[]; + dueDateRange?: { + start: string; + end: string; + }; + }; + accounts?: { + industry?: string[]; + type?: string[]; + assignedTo?: string[]; + }; + opportunities?: { + stage?: OpportunityStage[]; + owner?: string[]; + amountRange?: { + min: number; + max: number; + }; + closeDateRange?: { + start: string; + end: string; + }; + }; +} + +export interface SalesforceSearchParams { + query?: string; + type?: 'events' | 'leads' | 'tasks' | 'accounts' | 'opportunities' | 'all'; + filters?: SalesforceFilters; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + page?: number; + limit?: number; +} + +// Statistics and Analytics Types +export interface SalesforceStats { + events: { + total: number; + upcoming: number; + completed: number; + byLocation: Record; + }; + leads: { + total: number; + new: number; + working: number; + qualified: number; + byStatus: Record; + bySource: Record; + }; + tasks: { + total: number; + open: number; + completed: number; + overdue: number; + byStatus: Record; + byPriority: Record; + }; + accounts: { + total: number; + byIndustry: Record; + byType: Record; + totalRevenue: number; + }; + opportunities: { + total: number; + open: number; + won: number; + lost: number; + totalValue: number; + averageDealSize: number; + byStage: Record; + pipelineValue: number; + winRate: number; + }; +} + diff --git a/src/modules/integrations/screens/IntegrationCategoryScreen.tsx b/src/modules/integrations/screens/IntegrationCategoryScreen.tsx index ecb641f..a5ab8d4 100644 --- a/src/modules/integrations/screens/IntegrationCategoryScreen.tsx +++ b/src/modules/integrations/screens/IntegrationCategoryScreen.tsx @@ -8,6 +8,7 @@ import { useDispatch } from 'react-redux'; import { setSelectedService } from '@/modules/integrations/store/integrationsSlice'; import type { AppDispatch } from '@/store/store'; import ZohoAuth from './ZohoAuth'; +import SalesforceAuth from './SalesforceAuth'; import { Modal } from 'react-native'; import httpClient from '@/services/http'; import { useNavigation } from '@react-navigation/native'; @@ -61,6 +62,7 @@ const IntegrationCategoryScreen: React.FC = ({ route }) => { const dispatch = useDispatch(); const navigation = useNavigation(); const [showZohoAuth, setShowZohoAuth] = React.useState(false); + const [showSalesforceAuth, setShowSalesforceAuth] = React.useState(false); const [pendingService, setPendingService] = React.useState(null); const [isCheckingToken, setIsCheckingToken] = React.useState(false); const [authenticatedServices, setAuthenticatedServices] = React.useState>(new Set()); @@ -102,11 +104,50 @@ const IntegrationCategoryScreen: React.FC = ({ route }) => { } }; + // Check for existing Salesforce token + const checkSalesforceToken = async (serviceKey: string, forceReauth: boolean = false) => { + try { + setIsCheckingToken(true); + + if (forceReauth) { + // Force re-authentication by showing auth modal + setPendingService(serviceKey); + setShowSalesforceAuth(true); + return; + } + + const response = await httpClient.get('/api/v1/users/decrypt-token?service_name=salesforce'); + const responseData = response.data as any; + + if (responseData.status === 'success' && responseData.data?.accessToken) { + // Token exists and is valid, mark as authenticated + // CrmNavigator will automatically show SalesforceDashboard based on selectedService + console.log('Salesforce token found, setting selected service'); + setAuthenticatedServices(prev => new Set([...prev, serviceKey])); + dispatch(setSelectedService(serviceKey)); + } else { + // No valid token, show auth modal + setPendingService(serviceKey); + setShowSalesforceAuth(true); + } + } catch (error) { + console.log('No valid Salesforce token found, showing auth modal'); + // Token doesn't exist or is invalid, show auth modal + setPendingService(serviceKey); + setShowSalesforceAuth(true); + } finally { + setIsCheckingToken(false); + } + }; + // Handle re-authentication const handleReAuthenticate = (serviceKey: string) => { + const isSalesforce = serviceKey === 'salesforce'; + const serviceTitle = isSalesforce ? 'Salesforce' : 'Zoho'; + Alert.alert( 'Re-authenticate', - 'This will allow you to change your organization or re-authorize access. Continue?', + `This will allow you to change your ${serviceTitle} organization or re-authorize access. Continue?`, [ { text: 'Cancel', @@ -114,7 +155,13 @@ const IntegrationCategoryScreen: React.FC = ({ route }) => { }, { text: 'Re-authenticate', - onPress: () => checkZohoToken(serviceKey, true), + onPress: () => { + if (isSalesforce) { + checkSalesforceToken(serviceKey, true); + } else { + checkZohoToken(serviceKey, true); + } + }, }, ] ); @@ -129,6 +176,8 @@ const IntegrationCategoryScreen: React.FC = ({ route }) => { ItemSeparatorComponent={() => } renderItem={({ item }) => { const requiresZohoAuth = item.key === 'zohoProjects' || item.key === 'zohoPeople' || item.key === 'zohoBooks' || item.key === 'zohoCRM'; + const requiresSalesforceAuth = item.key === 'salesforce'; + const requiresAuth = requiresZohoAuth || requiresSalesforceAuth; return ( @@ -142,8 +191,10 @@ const IntegrationCategoryScreen: React.FC = ({ route }) => { if (requiresZohoAuth) { checkZohoToken(item.key); + } else if (requiresSalesforceAuth) { + checkSalesforceToken(item.key); } else { - // For non-Zoho services, navigate to Coming Soon screen + // For other services, navigate to Coming Soon screen navigation.navigate('ComingSoon' as never); } }} @@ -161,8 +212,8 @@ const IntegrationCategoryScreen: React.FC = ({ route }) => { )} - {/* Re-authentication button for Zoho services - always visible */} - {requiresZohoAuth && ( + {/* Re-authentication button for services that require auth */} + {requiresAuth && ( handleReAuthenticate(item.key)} @@ -190,7 +241,7 @@ const IntegrationCategoryScreen: React.FC = ({ route }) => { { - console.log('auth data i got',authData) + console.log('Zoho auth data received:', authData); setShowZohoAuth(false); if (pendingService) { // Mark service as authenticated @@ -209,6 +260,37 @@ const IntegrationCategoryScreen: React.FC = ({ route }) => { }} /> + + {/* Salesforce Auth Modal */} + setShowSalesforceAuth(false)} + > + { + console.log('Salesforce auth data received:', authData); + setShowSalesforceAuth(false); + if (pendingService) { + // Mark service as authenticated + // CrmNavigator will automatically show SalesforceDashboard based on selectedService + setAuthenticatedServices(prev => new Set([...prev, pendingService])); + dispatch(setSelectedService(pendingService)); + setPendingService(null); + } + }} + onAuthError={(error) => { + console.error('Salesforce auth error:', error); + setShowSalesforceAuth(false); + setPendingService(null); + }} + onClose={() => { + setShowSalesforceAuth(false); + setPendingService(null); + }} + /> + ); }; diff --git a/src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md b/src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md new file mode 100644 index 0000000..138bff6 --- /dev/null +++ b/src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md @@ -0,0 +1,600 @@ +# Salesforce OAuth - Backend Callback Flow + +## Overview +This document explains the backend-handled OAuth callback flow for Salesforce authentication, where the backend receives the authorization code directly from Salesforce and handles the token exchange. + +--- + +## Architecture Flow + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Mobile App โ”‚ +โ”‚ (SalesforceAuth)โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ 1. User taps Salesforce + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Build OAuth URL with backend callback: โ”‚ +โ”‚ https://login.salesforce.com/services/oauth2/authorize โ”‚ +โ”‚ ?client_id=YOUR_CLIENT_ID โ”‚ +โ”‚ &redirect_uri=YOUR_BACKEND_URL/callback โ”‚ +โ”‚ ?user_uuid=USER_ID&service_name=salesforce โ”‚ +โ”‚ &response_type=code โ”‚ +โ”‚ &scope=api refresh_token offline_access... โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ 2. Open in WebView + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Salesforce โ”‚ + โ”‚ Login Page โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ 3. User authenticates + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Salesforce redirects to backend callback: โ”‚ + โ”‚ https://YOUR_BACKEND/callback โ”‚ + โ”‚ ?user_uuid=USER_ID โ”‚ + โ”‚ &service_name=salesforce โ”‚ + โ”‚ &code=AUTHORIZATION_CODE โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ 4. Backend receives callback + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Your Backend Server โ”‚ + โ”‚ โ”‚ + โ”‚ โ€ข Extract: authorization_code, user_uuid, โ”‚ + โ”‚ service_name from query params โ”‚ + โ”‚ โ”‚ + โ”‚ โ€ข Exchange code for tokens with Salesforce:โ”‚ + โ”‚ POST /services/oauth2/token โ”‚ + โ”‚ grant_type=authorization_code โ”‚ + โ”‚ code=CODE โ”‚ + โ”‚ client_id=YOUR_CLIENT_ID โ”‚ + โ”‚ client_secret=YOUR_CLIENT_SECRET โ”‚ + โ”‚ redirect_uri=YOUR_CALLBACK_URL โ”‚ + โ”‚ โ”‚ + โ”‚ โ€ข Store tokens (encrypted) in database โ”‚ + โ”‚ associated with user_uuid โ”‚ + โ”‚ โ”‚ + โ”‚ โ€ข Return HTML page or redirect with โ”‚ + โ”‚ status indicator โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ 5. Backend responds + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Backend returns success/error page: โ”‚ + โ”‚ โ”‚ + โ”‚ Success: ?status=success โ”‚ + โ”‚ OR ?success=true โ”‚ + โ”‚ โ”‚ + โ”‚ Error: ?status=error โ”‚ + โ”‚ &message=Error+description โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ 6. WebView detects callback response + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Mobile App โ”‚ + โ”‚ โ€ข Detect backend callback URL โ”‚ + โ”‚ โ€ข Check for status=success or error โ”‚ + โ”‚ โ€ข Show success/error to user โ”‚ + โ”‚ โ€ข Close auth modal โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +## Backend Implementation + +### Endpoint: `GET /api/v1/users/oauth/callback` + +#### Query Parameters (Received from Salesforce) +```typescript +{ + user_uuid: string; // User ID passed in redirect_uri + service_name: string; // 'salesforce' passed in redirect_uri + code: string; // Authorization code from Salesforce + state?: string; // Optional CSRF token +} +``` + +#### Implementation Steps + +##### 1. Extract Query Parameters +```javascript +const { code: authorization_code, user_uuid, service_name } = req.query; + +// Validate required parameters +if (!authorization_code || !user_uuid || !service_name) { + return res.redirect(`/oauth/error?message=Missing required parameters`); +} + +// Verify service_name is 'salesforce' +if (service_name !== 'salesforce') { + return res.redirect(`/oauth/error?message=Invalid service name`); +} +``` + +##### 2. Exchange Authorization Code for Tokens +```javascript +const axios = require('axios'); + +try { + const tokenResponse = await axios.post( + 'https://login.salesforce.com/services/oauth2/token', + null, + { + params: { + grant_type: 'authorization_code', + code: authorization_code, + client_id: process.env.SALESFORCE_CLIENT_ID, + client_secret: process.env.SALESFORCE_CLIENT_SECRET, + redirect_uri: `${process.env.BACKEND_URL}/api/v1/users/oauth/callback?user_uuid=${user_uuid}&service_name=salesforce` + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); + + const { + access_token, + refresh_token, + instance_url, + id, + issued_at, + signature + } = tokenResponse.data; + + // Store tokens... + +} catch (error) { + console.error('Token exchange failed:', error); + return res.redirect(`/oauth/error?message=${encodeURIComponent(error.message)}`); +} +``` + +##### 3. Store Tokens Securely +```javascript +// Encrypt tokens before storing +const encryptedAccessToken = encrypt(access_token); +const encryptedRefreshToken = encrypt(refresh_token); + +await db.integrations.upsert({ + where: { + user_id: user_uuid, + service_name: 'salesforce' + }, + update: { + access_token: encryptedAccessToken, + refresh_token: encryptedRefreshToken, + instance_url: instance_url, + token_issued_at: new Date(parseInt(issued_at)), + updated_at: new Date() + }, + create: { + user_id: user_uuid, + service_name: 'salesforce', + access_token: encryptedAccessToken, + refresh_token: encryptedRefreshToken, + instance_url: instance_url, + token_issued_at: new Date(parseInt(issued_at)), + created_at: new Date() + } +}); +``` + +##### 4. Return Success Response +```javascript +// Option 1: Redirect to success page with status +res.redirect('/oauth/success?status=success&service=salesforce'); + +// Option 2: Return HTML page that mobile app can detect +res.send(` + + + + Authentication Successful + + + + +
+
โœ“
+

Authentication Successful!

+

Your Salesforce account has been connected.

+

You can close this window now.

+
+ + + +`); +``` + +##### 5. Handle Errors +```javascript +// If token exchange or storage fails +res.send(` + + + + Authentication Failed + + + + +
+
โœ—
+

Authentication Failed

+

${errorMessage || 'An error occurred during authentication.'}

+

Please try again.

+
+ + + +`); +``` + +--- + +## Mobile App Detection Logic + +The mobile app (SalesforceAuth component) detects the backend callback using these helper functions: + +### Detection Functions +```typescript +// Check if URL is the backend callback URI +const isBackendCallbackUri = (url: string): boolean => { + return url.includes('/api/v1/users/oauth/callback'); +}; + +// Check if backend callback indicates success +const isCallbackSuccess = (url: string): boolean => { + const status = getQueryParamFromUrl(url, 'status'); + const success = getQueryParamFromUrl(url, 'success'); + return status === 'success' || success === 'true'; +}; + +// Check if backend callback indicates error +const isCallbackError = (url: string): boolean => { + const status = getQueryParamFromUrl(url, 'status'); + const error = getQueryParamFromUrl(url, 'error'); + return status === 'error' || status === 'failure' || !!error; +}; +``` + +### Navigation State Handler +```typescript +handleNavigationStateChange = (navState) => { + const { url, loading } = navState; + + // Check if this is the backend callback URL + if (isBackendCallbackUri(url)) { + console.log('Backend callback detected'); + + // Show processing modal + setState({ processing: true, processingStep: 'Processing...' }); + + // Check for success + if (isCallbackSuccess(url)) { + handleBackendSuccess(); + return; + } + + // Check for error + if (isCallbackError(url)) { + const errorMessage = getQueryParamFromUrl(url, 'message') || 'Authentication failed'; + handleBackendError(errorMessage); + return; + } + } +}; +``` + +--- + +## Complete Backend Example (Node.js/Express) + +```javascript +const express = require('express'); +const axios = require('axios'); +const crypto = require('crypto'); + +const router = express.Router(); + +// Encryption utilities +function encrypt(text) { + const algorithm = 'aes-256-gcm'; + const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex'); + const iv = crypto.randomBytes(16); + + const cipher = crypto.createCipheriv(algorithm, key, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; +} + +// OAuth Callback Endpoint +router.get('/api/v1/users/oauth/callback', async (req, res) => { + const { code: authorization_code, user_uuid, service_name } = req.query; + + console.log('[OAuth Callback] Received:', { user_uuid, service_name, hasCode: !!authorization_code }); + + // Validate required parameters + if (!authorization_code || !user_uuid || !service_name) { + return res.send(errorPage('Missing required parameters')); + } + + if (service_name !== 'salesforce') { + return res.send(errorPage('Invalid service name')); + } + + try { + // Exchange authorization code for tokens + const tokenResponse = await axios.post( + 'https://login.salesforce.com/services/oauth2/token', + null, + { + params: { + grant_type: 'authorization_code', + code: authorization_code, + client_id: process.env.SALESFORCE_CLIENT_ID, + client_secret: process.env.SALESFORCE_CLIENT_SECRET, + redirect_uri: `${process.env.BACKEND_URL}/api/v1/users/oauth/callback?user_uuid=${user_uuid}&service_name=salesforce` + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); + + const { + access_token, + refresh_token, + instance_url, + id, + issued_at + } = tokenResponse.data; + + console.log('[OAuth Callback] Token exchange successful'); + + // Encrypt and store tokens + const encryptedAccessToken = encrypt(access_token); + const encryptedRefreshToken = encrypt(refresh_token); + + await db.integrations.upsert({ + where: { + user_id: user_uuid, + service_name: 'salesforce' + }, + update: { + access_token: encryptedAccessToken, + refresh_token: encryptedRefreshToken, + instance_url: instance_url, + token_issued_at: new Date(parseInt(issued_at)), + updated_at: new Date() + }, + create: { + user_id: user_uuid, + service_name: 'salesforce', + access_token: encryptedAccessToken, + refresh_token: encryptedRefreshToken, + instance_url: instance_url, + token_issued_at: new Date(parseInt(issued_at)), + created_at: new Date() + } + }); + + console.log('[OAuth Callback] Tokens stored successfully'); + + // Return success page + return res.send(successPage()); + + } catch (error) { + console.error('[OAuth Callback] Error:', error); + return res.send(errorPage(error.message)); + } +}); + +// Success page HTML +function successPage() { + return ` + + + + Success + + + + +
+
โœ“
+

Authentication Successful!

+

Your Salesforce account has been connected.

+
+ + + + `; +} + +// Error page HTML +function errorPage(message) { + return ` + + + + Error + + + + +
+
โœ—
+

Authentication Failed

+

${message || 'An error occurred.'}

+
+ + + + `; +} + +module.exports = router; +``` + +--- + +## Configuration Checklist + +### Salesforce Connected App +- โœ… Callback URL: `https://YOUR_BACKEND_URL/api/v1/users/oauth/callback` +- โœ… OAuth Scopes: api, refresh_token, offline_access, profile, email, address, phone + +### Mobile App (SalesforceAuth.tsx) +- โœ… `BACKEND_BASE_URL`: Your backend domain +- โœ… `CALLBACK_PATH`: `/api/v1/users/oauth/callback` +- โœ… `CLIENT_ID`: Salesforce Consumer Key + +### Backend Environment Variables +- โœ… `SALESFORCE_CLIENT_ID`: Consumer Key from Salesforce +- โœ… `SALESFORCE_CLIENT_SECRET`: Consumer Secret from Salesforce +- โœ… `BACKEND_URL`: Your public backend URL +- โœ… `ENCRYPTION_KEY`: 32-byte hex string for token encryption + +--- + +## Testing + +1. **Test OAuth Flow**: + - Open app โ†’ CRM & Sales โ†’ Salesforce + - Complete Salesforce login + - Verify redirect to backend callback + - Check backend logs for token exchange + - Verify success page appears + - Confirm modal closes automatically + +2. **Test Error Handling**: + - Simulate invalid credentials + - Simulate network failure + - Verify error page displays + - Confirm retry functionality works + +3. **Verify Token Storage**: + - Check database for encrypted tokens + - Verify user_id association + - Confirm refresh_token is stored + +--- + +## Security Considerations + +1. **HTTPS Only**: Always use HTTPS for callback URLs +2. **Token Encryption**: Encrypt tokens before storing in database +3. **Client Secret**: Never expose client secret to mobile app +4. **State Parameter**: Add CSRF protection using state parameter +5. **Token Expiration**: Implement token refresh logic +6. **Access Control**: Validate user_uuid belongs to authenticated session + +--- + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| Callback URL mismatch | Ensure Salesforce Connected App callback URL exactly matches backend URL with query params | +| Token exchange fails | Check client_id and client_secret are correct | +| Authorization code expired | Codes expire in 15 minutes - ensure timely exchange | +| Backend not reachable | Verify backend URL is publicly accessible (use ngrok for local testing) | +| WebView doesn't detect success | Ensure backend returns HTML with `?status=success` in URL | + +--- + +**Last Updated**: October 2025 +**Version**: 2.0.0 (Backend Callback Flow) + diff --git a/src/modules/integrations/screens/SALESFORCE_SETUP.md b/src/modules/integrations/screens/SALESFORCE_SETUP.md new file mode 100644 index 0000000..d31bb02 --- /dev/null +++ b/src/modules/integrations/screens/SALESFORCE_SETUP.md @@ -0,0 +1,452 @@ +# Salesforce OAuth Integration Setup Guide + +This guide will help you configure Salesforce OAuth authentication for your Centralized Reporting System application. + +## Table of Contents +1. [Prerequisites](#prerequisites) +2. [Create a Salesforce Connected App](#create-a-salesforce-connected-app) +3. [Configure OAuth Settings](#configure-oauth-settings) +4. [Update Application Configuration](#update-application-configuration) +5. [Test the Integration](#test-the-integration) +6. [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +Before you begin, ensure you have: +- A Salesforce account (Production or Sandbox) +- Administrator access to Salesforce +- Your backend API ready to handle OAuth token exchange +- React Native app with WebView capability + +--- + +## Create a Salesforce Connected App + +### Step 1: Access Setup +1. Log in to your Salesforce account +2. Click the **gear icon** (โš™๏ธ) in the top right corner +3. Select **Setup** from the dropdown menu + +### Step 2: Navigate to App Manager +1. In the Quick Find box, type **"App Manager"** +2. Click **App Manager** under Platform Tools > Apps +3. Click **New Connected App** button + +### Step 3: Basic Information +Fill in the following required fields: +- **Connected App Name**: `Centralized Reporting System` (or your preferred name) +- **API Name**: `Centralized_Reporting_System` (auto-populated) +- **Contact Email**: Your email address + +### Step 4: API (Enable OAuth Settings) +1. Check the box **"Enable OAuth Settings"** +2. **Callback URL**: Enter your redirect URI + ``` + centralizedreportingsystem://oauth/salesforce/callback + ``` + > Note: You can add multiple callback URLs for different environments (development, staging, production) + +3. **Selected OAuth Scopes**: Select the following scopes and move them to "Selected OAuth Scopes": + - Access the identity URL service (id, profile, email, address, phone) + - Access unique user identifiers (openid) + - Perform requests at any time (refresh_token, offline_access) + - Manage user data via APIs (api) + - Full access to all data (full) - Optional, use with caution + +4. **Additional OAuth Settings**: + - โœ… **Enable for Device Flow**: Check this if you want to support device authentication + - โœ… **Require Secret for Web Server Flow**: Recommended for security + - โœ… **Require Secret for Refresh Token Flow**: Recommended for security + - โœ… **Enable Authorization Code and Credentials Flow**: Check this + +5. Click **Save** button + +### Step 5: Wait for Salesforce Processing +After saving, Salesforce will process your app (usually 2-10 minutes). + +### Step 6: Retrieve OAuth Credentials +1. Once processing is complete, navigate back to **App Manager** +2. Find your Connected App and click the dropdown arrow (โ–ผ) +3. Select **View** +4. In the "API (Enable OAuth Settings)" section, you'll see: + - **Consumer Key** (Client ID) + - **Consumer Secret** (Client Secret) - Click "Click to reveal" + +> โš ๏ธ **Important**: Keep these credentials secure! Never commit them to public repositories. + +--- + +## Configure OAuth Settings + +### Environment-Specific Endpoints + +#### Production +``` +Auth Base URL: https://login.salesforce.com +``` + +#### Sandbox +``` +Auth Base URL: https://test.salesforce.com +``` + +### OAuth Scopes Explained + +| Scope | Description | +|-------|-------------| +| `api` | Access to all standard Salesforce REST APIs | +| `refresh_token` | Ability to refresh the access token | +| `offline_access` | Perform requests even when the user is offline | +| `id` | Access to the unique identifier for the user | +| `profile` | Access to user's profile information | +| `email` | Access to user's email address | +| `address` | Access to user's address | +| `phone` | Access to user's phone number | +| `openid` | OpenID Connect authentication | + +--- + +## Update Application Configuration + +### Step 1: Update SalesforceAuth.tsx + +Open `src/modules/integrations/screens/SalesforceAuth.tsx` and update the following: + +```typescript +const SALESFORCE_CONFIG = { + // Replace with your Consumer Key (Client ID) from Salesforce + CLIENT_ID: '3MVG9XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + + // This must match exactly what you configured in Salesforce Connected App + REDIRECT_URI: 'centralizedreportingsystem://oauth/salesforce/callback', + + // Use login.salesforce.com for production + // Use test.salesforce.com for sandbox + AUTH_BASE_URL: 'https://login.salesforce.com', + + RESPONSE_TYPE: 'code', + DISPLAY: 'touch', // Optimized for mobile +}; +``` + +### Step 2: Configure Deep Linking (Android) + +#### Android - Update AndroidManifest.xml +Add the intent filter to handle the OAuth callback: + +```xml + + + + + + + + + + + + + + + + + +``` + +### Step 3: Configure Deep Linking (iOS) + +#### iOS - Update Info.plist +Add URL types for the OAuth callback: + +```xml + +CFBundleURLTypes + + + CFBundleURLSchemes + + centralizedreportingsystem + + CFBundleURLName + com.yourcompany.centralizedreportingsystem + + +``` + +### Step 4: Backend Token Exchange Endpoint + +Your backend should have an endpoint to exchange the authorization code for an access token: + +```typescript +// Example backend endpoint +POST /api/v1/integrations/manage-token + +Request Body: +{ + "authorization_code": "aPrxXXXXXXXXXXXXXXXXXXXXXXXXX", + "id": "user-uuid", + "service_name": "salesforce", + "access_token": "user-session-token" +} +``` + +The backend should: +1. Receive the authorization code +2. Exchange it with Salesforce for an access token +3. Store the tokens securely (encrypted) +4. Return success/failure response + +#### Salesforce Token Exchange Request + +Your backend should make this request to Salesforce: + +```http +POST https://login.salesforce.com/services/oauth2/token +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code +&code={AUTHORIZATION_CODE} +&client_id={YOUR_CLIENT_ID} +&client_secret={YOUR_CLIENT_SECRET} +&redirect_uri={YOUR_REDIRECT_URI} +``` + +#### Expected Salesforce Response + +```json +{ + "access_token": "00D...ABC", + "refresh_token": "5Aep...XYZ", + "signature": "signature-value", + "scope": "api refresh_token offline_access id profile email", + "id_token": "eyJ...", + "instance_url": "https://yourinstance.salesforce.com", + "id": "https://login.salesforce.com/id/00D.../005...", + "token_type": "Bearer", + "issued_at": "1699999999999" +} +``` + +--- + +## Test the Integration + +### Testing Steps + +1. **Launch the App** + - Open your React Native application + - Navigate to **Integrations** > **CRM & Sales Integration** + +2. **Select Salesforce** + - Tap on the **Salesforce** option + - The Salesforce authentication WebView should open + +3. **Authenticate** + - Enter your Salesforce username and password + - If using Two-Factor Authentication, complete the verification + - Review and approve the OAuth permissions + +4. **Verify Success** + - The app should capture the authorization code + - Backend should exchange it for tokens + - You should see a success message + - The auth modal should close automatically + +5. **Check Data Sync** (if implemented) + - Navigate to the Salesforce dashboard + - Verify that data is being synced properly + +### Testing with Different Salesforce Orgs + +To test with different Salesforce organizations: +1. Tap the **Re-auth** button next to Salesforce +2. Confirm the re-authentication +3. Log in with a different Salesforce account + +--- + +## Troubleshooting + +### Common Issues and Solutions + +#### 1. **"redirect_uri_mismatch" Error** + +**Problem**: The redirect URI doesn't match what's configured in Salesforce. + +**Solution**: +- Ensure the redirect URI in `SalesforceAuth.tsx` exactly matches what's in the Salesforce Connected App +- Check for trailing slashes, capitalization, and special characters +- Both values must be identical: `centralizedreportingsystem://oauth/salesforce/callback` + +--- + +#### 2. **"invalid_client_id" Error** + +**Problem**: The Client ID is incorrect or the Connected App is not yet active. + +**Solution**: +- Wait 2-10 minutes after creating the Connected App for Salesforce to process it +- Double-check the Consumer Key (Client ID) from Salesforce matches your config +- Ensure there are no extra spaces or characters + +--- + +#### 3. **WebView Doesn't Load** + +**Problem**: The Salesforce login page doesn't appear. + +**Solution**: +- Check your internet connection +- Verify the `AUTH_BASE_URL` is correct (login.salesforce.com or test.salesforce.com) +- Check console logs for error messages +- Ensure WebView has internet permission (Android) + +--- + +#### 4. **App Doesn't Capture the Code** + +**Problem**: After authentication, the app doesn't proceed. + +**Solution**: +- Verify deep linking is configured correctly for both iOS and Android +- Check that the redirect URI scheme matches your app's URL scheme +- Test the deep link manually using ADB (Android) or xcrun (iOS) +- Check console logs for navigation state changes + +--- + +#### 5. **"access_denied" Error** + +**Problem**: User denied the OAuth permissions. + +**Solution**: +- User must approve all requested permissions +- If permissions are too broad, consider reducing the requested scopes +- Check if the Connected App is enabled for the user's profile + +--- + +#### 6. **Token Exchange Fails** + +**Problem**: Backend returns an error when exchanging the authorization code. + +**Solution**: +- Verify backend endpoint is working: `/api/v1/integrations/manage-token` +- Check that the backend has the correct Consumer Secret +- Authorization codes expire after 15 minutes - ensure timely exchange +- Check backend logs for detailed error messages +- Verify the authorization code is being sent correctly + +--- + +#### 7. **Sandbox vs Production Confusion** + +**Problem**: Using the wrong Salesforce environment. + +**Solution**: +- **Production**: Use `https://login.salesforce.com` +- **Sandbox**: Use `https://test.salesforce.com` +- Create separate Connected Apps for each environment +- Update the config based on your current environment + +--- + +### Debug Logging + +Enable comprehensive logging to troubleshoot issues: + +```typescript +// In SalesforceAuth.tsx, check console logs for: +console.log('[SalesforceAuth] Authorization code received:', authCode); +console.log('[SalesforceAuth] Response from manageToken:', response); +console.log('[SalesforceAuth] nav change url:', url, 'loading:', loading); +``` + +--- + +## Security Best Practices + +1. **Never Store Credentials in Code** + - Use environment variables or secure config management + - Never commit `.env` files with credentials to version control + +2. **Use HTTPS Only** + - Always use `https://` for Salesforce endpoints + - Never use `http://` in production + +3. **Implement Token Refresh** + - Store refresh tokens securely + - Implement automatic token refresh on expiration + - Handle refresh token failures gracefully + +4. **Encrypt Stored Tokens** + - Use encryption for tokens stored on device + - Use secure storage mechanisms (Keychain, KeyStore) + +5. **Validate State Parameter** + - Include CSRF protection using state parameter + - Verify state on callback to prevent CSRF attacks + +6. **Limit OAuth Scopes** + - Request only the minimum scopes needed + - Avoid using `full` scope unless absolutely necessary + +7. **Monitor Token Usage** + - Log token usage and refresh patterns + - Set up alerts for unusual authentication patterns + +--- + +## Additional Resources + +### Salesforce Documentation +- [Connected Apps Overview](https://help.salesforce.com/s/articleView?id=sf.connected_app_overview.htm) +- [OAuth 2.0 Authorization Flow](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_flow.htm) +- [OAuth Scopes](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_tokens_scopes.htm) + +### React Native Resources +- [React Native WebView](https://github.com/react-native-webview/react-native-webview) +- [Deep Linking in React Native](https://reactnative.dev/docs/linking) + +--- + +## Support + +If you encounter issues not covered in this guide: + +1. Check Salesforce Setup Audit Trail for any configuration errors +2. Review React Native console logs +3. Check backend API logs for token exchange errors +4. Verify network requests in the debugger +5. Test with both Production and Sandbox environments + +--- + +## Changelog + +### Version 1.0.0 +- Initial Salesforce OAuth integration +- Support for mobile OAuth flow +- Backend token exchange implementation +- Data sync scheduling + +--- + +## License + +This integration follows your application's license terms. + +--- + +**Last Updated**: October 2025 +**Maintained By**: Your Development Team + diff --git a/src/modules/integrations/screens/SalesforceAuth.tsx b/src/modules/integrations/screens/SalesforceAuth.tsx new file mode 100644 index 0000000..7278736 --- /dev/null +++ b/src/modules/integrations/screens/SalesforceAuth.tsx @@ -0,0 +1,689 @@ +import React, { useState, useRef, useCallback, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + Alert, + ActivityIndicator, + TouchableOpacity, + SafeAreaView, +} from 'react-native'; +import { WebView } from 'react-native-webview'; +import Icon from 'react-native-vector-icons/MaterialIcons'; +import { useTheme } from '@/shared/styles/useTheme'; +import { useNavigation } from '@react-navigation/native'; +import { manageToken } from '../services/integrationAPI'; +import { useSelector } from 'react-redux'; +import { RootState } from '@/store/store'; +import http from '@/services/http'; + +// Types +interface SalesforceAuthProps { + onAuthSuccess?: (authData: SalesforceAuthData) => void; + onAuthError?: (error: string) => void; + onClose?: () => void; +} + +interface SalesforceAuthData { + accessToken: string; + refreshToken?: string; + instanceUrl?: string; + id?: string; + issuedAt?: string; + signature?: string; + tokenType?: string; +} + +interface SalesforceAuthState { + loading: boolean; + error: string | null; + currentUrl: string; + processing: boolean; + processingStep: string; +} + +// Salesforce OAuth Configuration +const SALESFORCE_CONFIG = { + // Replace with your actual Salesforce Connected App credentials + CLIENT_ID: '3MVG9GBhY6wQjl2sueQtv2NXMm3EuWtEvOQoeKRAzYcgs2.AWhkCPFitVFPYyUkiLRRdIww2fpr48_Inokd3F', + // Backend callback URL that will handle the OAuth callback + BACKEND_BASE_URL: 'https://512acb53a4a4.ngrok-free.app', + CALLBACK_PATH: '/api/v1/users/oauth/callback', + // Use login.salesforce.com for production, test.salesforce.com for sandbox + AUTH_BASE_URL: 'https://test.salesforce.com', + RESPONSE_TYPE: 'code', + DISPLAY: 'touch', // Optimized for mobile +}; + +// Salesforce OAuth scopes - request access to various Salesforce APIs +const SALESFORCE_SCOPES = [ + 'api', // Access to REST API + 'refresh_token', // Ability to use refresh token + 'offline_access', // Perform requests at any time + // 'id', // Access to unique identifier + 'profile', // Access to profile information + 'email', // Access to email address + 'address', // Access to address information + 'phone', // Access to phone number + // 'openid', // OpenID Connect +].join(' '); + +// Build Salesforce OAuth URL with user-specific callback +const buildSalesforceAuthUrl = (userUuid: string): string => { + const baseUrl = `${SALESFORCE_CONFIG.AUTH_BASE_URL}/services/oauth2/authorize`; + + // Build the redirect URI with query parameters that backend expects + const redirectUri = `${SALESFORCE_CONFIG.BACKEND_BASE_URL}${SALESFORCE_CONFIG.CALLBACK_PATH}?user_uuid=${encodeURIComponent(userUuid)}&service_name=salesforce`; + + const params = new URLSearchParams({ + client_id: SALESFORCE_CONFIG.CLIENT_ID, + redirect_uri: redirectUri, + response_type: SALESFORCE_CONFIG.RESPONSE_TYPE, + display: SALESFORCE_CONFIG.DISPLAY, + scope: SALESFORCE_SCOPES, + // Add state parameter for security (CSRF protection) + state: Math.random().toString(36).substring(7), + }); + + console.log('[SalesforceAuth] Built OAuth URL:', `${baseUrl}?${params.toString()}`); + console.log('[SalesforceAuth] Redirect URI:', redirectUri); + return `${baseUrl}?${params.toString()}`; +}; + +// Safe query param parser for React Native +const getQueryParamFromUrl = (url: string, key: string): string | null => { + try { + const queryIndex = url.indexOf('?'); + if (queryIndex === -1) return null; + const query = url.substring(queryIndex + 1); + const pairs = query.split('&'); + for (let i = 0; i < pairs.length; i += 1) { + const [rawK, rawV] = pairs[i].split('='); + const k = decodeURIComponent(rawK || ''); + if (k === key) { + return decodeURIComponent(rawV || ''); + } + } + return null; + } catch (e) { + return null; + } +}; + +// Parse all query params from URL into a plain object +const getAllQueryParamsFromUrl = (url: string): Record => { + const result: Record = {}; + try { + const queryIndex = url.indexOf('?'); + if (queryIndex === -1) return result; + const query = url.substring(queryIndex + 1); + const pairs = query.split('&'); + for (let i = 0; i < pairs.length; i += 1) { + const [rawK, rawV] = pairs[i].split('='); + const k = decodeURIComponent(rawK || ''); + const v = decodeURIComponent(rawV || ''); + if (!k) continue; + // Coerce numeric values when appropriate + const numeric = /^-?\d+$/.test(v) ? Number(v) : v; + result[k] = numeric; + } + } catch (e) { + // swallow errors, return best-effort result + } + return result; +}; + +// Extract authorization code from URL +const extractAuthCode = (url: string): string | null => { + return getQueryParamFromUrl(url, 'code'); +}; + +// Check if URL is the backend callback URI +const isBackendCallbackUri = (url: string): boolean => { + return url.includes(SALESFORCE_CONFIG.CALLBACK_PATH); +}; + +// Check if backend callback indicates success +const isCallbackSuccess = (url: string): boolean => { + const status = getQueryParamFromUrl(url, 'status'); + const success = getQueryParamFromUrl(url, 'success'); + return status === 'success' || success === 'true'; +}; + +// Check if backend callback indicates error +const isCallbackError = (url: string): boolean => { + const status = getQueryParamFromUrl(url, 'status'); + const error = getQueryParamFromUrl(url, 'error'); + return status === 'error' || status === 'failure' || !!error; +}; + +const SalesforceAuth: React.FC = ({ + onAuthSuccess, + onAuthError, + onClose, +}) => { + const { colors, spacing, fonts, shadows } = useTheme(); + const webViewRef = useRef(null); + const navigation = useNavigation(); + const { user, accessToken } = useSelector((state: RootState) => state.auth); + + const [state, setState] = useState({ + loading: true, + error: null, + currentUrl: buildSalesforceAuthUrl(user?.uuid || ''), + processing: false, + processingStep: '', + }); + + // Handle authorization code from callback URL + const handleAuthorizationCode = useCallback(async (authCode: string) => { + console.log('[SalesforceAuth] Authorization code captured:', authCode); + console.log('[SalesforceAuth] Manually hitting callback URL with query params'); + + // Show processing modal + setState(prev => ({ + ...prev, + processing: true, + processingStep: 'Connecting to Salesforce and storing credentials...' + })); + + try { + // Manually hit the callback URL endpoint with query parameters + // This mimics what Salesforce would have sent directly to the backend + const response = await http.get('/api/v1/users/oauth/callback', { + authorization_code: authCode, + user_uuid: user?.uuid || '', + service_name: 'salesforce' + }); + + console.log('[SalesforceAuth] Response from callback endpoint:', response); + + // Check if response status is success + if (response?.data && typeof response.data === 'object' && 'status' in response.data && response.data.status === 'success') { + console.log('[SalesforceAuth] Token exchange successful'); + + // Update processing step + setState(prev => ({ + ...prev, + processingStep: 'Salesforce authentication successful!' + })); + + // Wait a moment to show success message + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Hide processing modal and call success callback + setState(prev => ({ ...prev, processing: false, processingStep: '' })); + + // Call success callback + onAuthSuccess?.({ + accessToken: authCode, + tokenType: 'authorization_code', + }); + } else { + const errorMessage = response?.data && typeof response.data === 'object' && 'message' in response.data + ? String(response.data.message) + : 'Failed to authenticate with Salesforce'; + console.warn('[SalesforceAuth] Token exchange failed:', errorMessage); + throw new Error(errorMessage); + } + } catch (error) { + console.error('[SalesforceAuth] Error during token exchange:', error); + + // Hide processing modal + setState(prev => ({ ...prev, processing: false, processingStep: '' })); + + // Call error callback + const errorMessage = error instanceof Error ? error.message : 'Authentication failed'; + onAuthError?.(errorMessage); + + // Show error alert + Alert.alert( + 'Authentication Error', + errorMessage, + [ + { + text: 'Retry', + onPress: () => { + setState(prev => ({ + ...prev, + error: null, + currentUrl: buildSalesforceAuthUrl(user?.uuid || '') + })); + webViewRef.current?.reload(); + }, + }, + { + text: 'Cancel', + onPress: onClose, + style: 'cancel', + }, + ] + ); + } + }, [onAuthSuccess, onAuthError, onClose, user?.uuid]); + + // Handle backend callback error + const handleBackendError = useCallback((url: string) => { + const errorMessage = getQueryParamFromUrl(url, 'message') || + getQueryParamFromUrl(url, 'error_description') || + 'Failed to authenticate with Salesforce'; + + console.error('[SalesforceAuth] Backend callback error:', errorMessage); + + // Hide processing modal + setState(prev => ({ + ...prev, + processing: false, + processingStep: '', + error: errorMessage + })); + + // Call error callback + onAuthError?.(errorMessage); + + // Show error alert + Alert.alert( + 'Authentication Error', + errorMessage, + [ + { + text: 'Retry', + onPress: () => { + setState(prev => ({ + ...prev, + error: null, + currentUrl: buildSalesforceAuthUrl(user?.uuid || '') + })); + webViewRef.current?.reload(); + }, + }, + { + text: 'Cancel', + onPress: onClose, + style: 'cancel', + }, + ] + ); + }, [onAuthError, onClose, user?.uuid]); + + // Handle WebView navigation state changes + const handleNavigationStateChange = useCallback((navState: any) => { + const { url, loading } = navState; + console.log('[SalesforceAuth] nav change url:', url, 'loading:', loading); + + setState(prev => ({ + ...prev, + loading, + currentUrl: url, + })); + + // Check if this is the backend callback URL (after Salesforce redirects) + if (isBackendCallbackUri(url)) { + console.log('[SalesforceAuth] Backend callback detected'); + + // Extract authorization code from the URL + const authCode = extractAuthCode(url); + console.log('[SalesforceAuth] Authorization code in URL:', authCode); + + // If we have an authorization code, capture it and send to backend + if (authCode && !state.processing) { + console.log('[SalesforceAuth] Capturing authorization code and sending to backend'); + handleAuthorizationCode(authCode); + return; + } + + // Check for success or error status (if backend already processed and redirected) + if (isCallbackSuccess(url)) { + console.log('[SalesforceAuth] Backend callback indicates success (already processed)'); + // Just close the modal - backend already handled everything + setState(prev => ({ ...prev, processing: false })); + onAuthSuccess?.({ + accessToken: 'token_exchanged_by_backend', + tokenType: 'Bearer', + }); + return; + } + + if (isCallbackError(url)) { + console.log('[SalesforceAuth] Backend callback indicates error'); + handleBackendError(url); + return; + } + + // If no code and no status, show processing + if (!state.processing) { + setState(prev => ({ + ...prev, + processing: true, + processingStep: 'Processing Salesforce authentication...' + })); + } + return; + } + + // Handle Salesforce OAuth errors (before redirect to backend) + const oauthError = getQueryParamFromUrl(url, 'error'); + if (oauthError) { + const errorDescription = getQueryParamFromUrl(url, 'error_description') || oauthError; + console.error('[SalesforceAuth] OAuth error:', errorDescription); + handleAuthError(errorDescription); + return; + } + }, [state.processing, handleAuthorizationCode, handleBackendError, onAuthSuccess]); + + // Allow WebView to load all URLs (backend callback is HTTP, not custom scheme) + const handleShouldStartLoadWithRequest = useCallback((request: any) => { + const { url } = request || {}; + if (!url) return true; + + // Log for debugging + console.log('[SalesforceAuth] onShouldStart load:', url); + + // Allow all HTTP/HTTPS URLs (backend callback will be handled in handleNavigationStateChange) + return true; + }, []); + + // Handle authentication error + const handleAuthError = useCallback((error: string) => { + setState(prev => ({ ...prev, error, loading: false })); + onAuthError?.(error); + + Alert.alert( + 'Salesforce Authentication Error', + error, + [ + { + text: 'Retry', + onPress: () => { + setState(prev => ({ + ...prev, + error: null, + currentUrl: buildSalesforceAuthUrl(user?.uuid || '') + })); + webViewRef.current?.reload(); + }, + }, + { + text: 'Cancel', + onPress: onClose, + style: 'cancel', + }, + ] + ); + }, [onAuthError, onClose, user?.uuid]); + + // Handle WebView error + const handleWebViewError = useCallback((syntheticEvent: any) => { + const { nativeEvent } = syntheticEvent; + console.error('WebView error:', nativeEvent); + handleAuthError('Failed to load Salesforce login page. Please check your internet connection.'); + }, [handleAuthError]); + + const handleLoadStart = useCallback((e: any) => { + console.log('[SalesforceAuth] load start:', e?.nativeEvent?.url); + }, []); + + const handleLoadEnd = useCallback((e: any) => { + console.log('[SalesforceAuth] load end:', e?.nativeEvent?.url); + }, []); + + // Handle close button press + const handleClose = useCallback(() => { + onClose?.(); + }, [onClose]); + + // Handle reload + const handleReload = useCallback(() => { + setState(prev => ({ + ...prev, + error: null, + currentUrl: buildSalesforceAuthUrl(user?.uuid || '') + })); + webViewRef.current?.reload(); + }, [user?.uuid]); + + return ( + + {/* Header */} + + + + + Salesforce Authentication + + + + + + + + + {/* Error State */} + {state.error && ( + + + + Authentication Failed + + + {state.error} + + + + Try Again + + + + )} + + {/* Processing Modal */} + {state.processing && ( + + + + + + + Setting Up Salesforce + + + {state.processingStep} + + + + + + + + + )} + + {/* Loading Overlay */} + {state.loading && !state.error && ( + + + + Loading Salesforce Login... + + + )} + + {/* WebView */} + {!state.error && !state.processing && ( + ( + + + + Loading... + + + )} + /> + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#E2E8F0', + }, + headerContent: { + flexDirection: 'row', + alignItems: 'center', + }, + headerTitle: { + fontSize: 18, + marginLeft: 8, + }, + closeButton: { + width: 32, + height: 32, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 32, + }, + errorTitle: { + fontSize: 20, + marginTop: 16, + marginBottom: 8, + }, + errorMessage: { + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + marginBottom: 24, + }, + retryButton: { + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, + }, + retryButtonText: { + fontSize: 16, + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + loadingText: { + fontSize: 16, + marginTop: 16, + }, + webView: { + flex: 1, + }, + webViewLoading: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + }, + webViewLoadingText: { + fontSize: 14, + marginTop: 8, + }, + processingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + zIndex: 2000, + }, + processingModal: { + borderRadius: 16, + padding: 32, + alignItems: 'center', + minWidth: 280, + maxWidth: 320, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 8, + }, + processingIconContainer: { + marginBottom: 20, + }, + processingTitle: { + fontSize: 20, + marginBottom: 12, + textAlign: 'center', + }, + processingStep: { + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + marginBottom: 20, + }, + processingDots: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + marginHorizontal: 4, + }, +}); + +export default SalesforceAuth; + diff --git a/src/services/http.ts b/src/services/http.ts index ffe7536..b5e0d0d 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -8,9 +8,9 @@ import { clearSelectedService } from '@/modules/integrations/store/integrationsS let pendingRequest: any = null; const http = create({ - // baseURL: 'http://192.168.1.20:4000', - baseURL: 'http://160.187.167.216', - // baseURL: 'https://angry-gifts-shave.loca.lt', + // baseURL: 'http://192.168.1.17:4000', + // baseURL: 'http://160.187.167.216', + baseURL: 'https://512acb53a4a4.ngrok-free.app', timeout: 10000, }); diff --git a/src/store/store.ts b/src/store/store.ts index e6538b2..ac3320d 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -10,6 +10,7 @@ import zohoProjectsSlice from '@/modules/zohoProjects/store/zohoProjectsSlice'; import profileSlice from '@/modules/profile/store/profileSlice'; import integrationsSlice from '@/modules/integrations/store/integrationsSlice'; import crmSlice from '@/modules/crm/zoho/store/crmSlice'; +import salesforceCrmSlice from '@/modules/crm/salesforce/store/salesforceCrmSlice'; import zohoBooksSlice from '@/modules/finance/zoho/store/zohoBooksSlice'; const rootReducer = combineReducers({ @@ -19,6 +20,7 @@ const rootReducer = combineReducers({ profile: profileSlice.reducer, integrations: integrationsSlice.reducer, crm: crmSlice, + salesforceCrm: salesforceCrmSlice, zohoBooks: zohoBooksSlice, ui: uiSlice.reducer, }); @@ -26,7 +28,7 @@ const rootReducer = combineReducers({ const persistConfig = { key: 'root', storage: AsyncStorage, - whitelist: ['auth', 'zohoPeople', 'zohoProjects', 'profile', 'integrations', 'crm', 'zohoBooks'], + whitelist: ['auth', 'zohoPeople', 'zohoProjects', 'profile', 'integrations', 'crm', 'salesforceCrm', 'zohoBooks'], blacklist: ['ui'], };