diff --git a/.dev_session/SESSION_INFO b/.dev_session/SESSION_INFO index 9bed2527..328b9025 100644 --- a/.dev_session/SESSION_INFO +++ b/.dev_session/SESSION_INFO @@ -1 +1 @@ -{"task_id": "2f988f42-8544-800e-abc1-d1b1c56ade4d", "token": "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", "session_start_time": "2026-02-04T20:42:43.587330"} \ No newline at end of file +{"task_id": "2ff88f42-8544-8000-8314-c9013414d1d0", "token": "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", "session_start_time": "2026-02-06T12:51:34.301296"} \ No newline at end of file diff --git a/SUPEROFFICE_INTEGRATION_PLAN.md b/SUPEROFFICE_INTEGRATION_PLAN.md index 47265810..3d5875b4 100644 --- a/SUPEROFFICE_INTEGRATION_PLAN.md +++ b/SUPEROFFICE_INTEGRATION_PLAN.md @@ -95,6 +95,31 @@ Folgende Felder sollten am Objekt `Company` (bzw. `Contact` in SuperOffice-Termi * **Scope:** Der API-User benötigt Lesezugriff auf `Contact` und Schreibzugriff auf die `UDFs`. * **Datenschutz:** Es werden nur Firmendaten (Name, Webseite, Stadt) übertragen. Personenbezogene Ansprechpartner bleiben im CRM und werden nicht an die KI gesendet. +### 4.1. POC Erkenntnisse & Manueller Setup-Prozess (Feb 2026) + +Während des initialen Proof of Concepts (POC) wurde der Authentifizierungs-Flow für die SOD-Umgebung (SuperOffice Development) erfolgreich etabliert. Dabei wurden folgende wichtige Erkenntnisse gewonnen: + +**Problemstellung:** Ein direkter "Client Credentials Flow" (rein maschinell) ist mit dem Anwendungstyp "empty" nicht möglich. Stattdessen ist ein einmaliger, interaktiver Schritt notwendig, um einen langlebigen `refresh_token` zu generieren, der dann für die automatisierte Kommunikation genutzt werden kann. + +**Durchgeführte Schritte zur Token-Generierung:** + +1. **App-Konfiguration:** Im SuperOffice Developer Portal wurde für die Anwendung "Robo_GTM-Engine" die URL `http://localhost` als "Allowed redirect URL" hinzugefügt. +2. **Autorisierungs-URL:** Ein Benutzer hat die folgende, speziell konstruierte URL im Browser geöffnet, um den Autorisierungsprozess zu starten: + `https://sod.superoffice.com/login/common/oauth/authorize?client_id=[IHRE_CLIENT_ID]&redirect_uri=http%3A%2F%2Flocalhost&response_type=code` +3. **Manuelle Zustimmung:** Der Benutzer hat sich in SuperOffice eingeloggt und der Anwendung die angeforderten Berechtigungen erteilt. +4. **Code-Extraktion:** Nach der Zustimmung wurde der Browser auf eine `localhost`-URL umgeleitet. Obwohl die Seite nicht geladen wurde, enthielt die URL den notwendigen `authorization_code` (z.B. `http://localhost/?code=ABC-123...`). +5. **Token-Austausch:** Mit einem Skript wurde dieser einmalige `code` an den SuperOffice Token-Endpunkt gesendet. Im Gegenzug wurden ein kurzlebiger `access_token` und der entscheidende, langlebige `refresh_token` empfangen. +6. **Sichere Speicherung:** Der `refresh_token` wurde in der `.env`-Datei des Connectors als `SO_REFRESH_TOKEN` hinterlegt. + +**Ergebnis & Aktueller Blocker:** + +* **Erfolg:** Der Authentifizierungs-Handshake ist erfolgreich. Der Connector kann den `SO_REFRESH_TOKEN` nutzen, um jederzeit vollautomatisch neue, gültige `access_token` von SuperOffice zu erhalten. +* **Blocker:** Trotz gültigem `access_token` werden alle nachfolgenden API-Aufrufe (z.B. an `/api/v1/User/currentPrincipal`) von SuperOffice auf die Login-Seite umgeleitet (`HTTP 302 Found`). Dies ist ein klares Indiz dafür, dass der Token zwar gültig, aber nicht für den API-Zugriff **autorisiert** ist. + +**Empfehlung für die IT:** + +Die Ursache für die fehlende Autorisierung liegt sehr wahrscheinlich im Anwendungstyp "empty". Um eine echte Server-zu-Server-Integration zu ermöglichen, **muss der Anwendungstyp im SuperOffice Developer Portal auf "Server to server" geändert werden.** Dies wird voraussichtlich erfordern, dass die Schritte 2-6 erneut durchgeführt werden, um neue Tokens mit den korrekten Berechtigungen zu erhalten. + ## 5. Vorbereitung für die IT Um den Connector in Betrieb zu nehmen, benötigen wir: diff --git a/connector-superoffice/Dockerfile b/connector-superoffice/Dockerfile deleted file mode 100644 index 7cd99fe7..00000000 --- a/connector-superoffice/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.9-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -CMD ["python", "main.py"] diff --git a/connector-superoffice/__init__.py b/connector-superoffice/__init__.py new file mode 100644 index 00000000..3596aa42 --- /dev/null +++ b/connector-superoffice/__init__.py @@ -0,0 +1 @@ +# This file makes the directory a Python package diff --git a/connector-superoffice/auth_handler.py b/connector-superoffice/auth_handler.py new file mode 100644 index 00000000..a33f0b7f --- /dev/null +++ b/connector-superoffice/auth_handler.py @@ -0,0 +1,121 @@ +import os +import requests +import logging +import time + +logger = logging.getLogger(__name__) + +class AuthHandler: + """ + Handles authentication against the SuperOffice API using an initial Authorization Code + exchange and subsequent Refresh Token flow. + """ + def __init__(self): + self.client_id = os.getenv("SO_SOD") + self.client_secret = os.getenv("SO_CLIENT_SECRET") + self.refresh_token = os.getenv("SO_REFRESH_TOKEN") # Will be set after initial code exchange + + # Token endpoint for both initial code exchange and refresh token flow + self.token_endpoint = "https://sod.superoffice.com/login/common/oauth/tokens" + + self._access_token = None + self._token_expiry_time = 0 + + if not self.client_id or not self.client_secret: + raise ValueError("SO_SOD and SO_CLIENT_SECRET must be set in the environment.") + + def _is_token_valid(self): + """Check if the current access token is still valid.""" + return self._access_token and time.time() < self._token_expiry_time + + def exchange_code_for_tokens(self, auth_code: str, redirect_uri: str): + """ + Exchanges an Authorization Code for an access token and a refresh token. + This is a one-time operation. + """ + logger.info("Exchanging Authorization Code for tokens...") + + payload = { + 'grant_type': 'authorization_code', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': auth_code, + 'redirect_uri': redirect_uri + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + } + + try: + response = requests.post(self.token_endpoint, data=payload, headers=headers) + response.raise_for_status() + token_data = response.json() + + self._access_token = token_data['access_token'] + self.refresh_token = token_data['refresh_token'] # Store the refresh token + self._token_expiry_time = time.time() + token_data['expires_in'] - 60 + + logger.info("Successfully exchanged code for tokens.") + return token_data + except requests.exceptions.HTTPError as http_err: + logger.error(f"HTTP error during code exchange: {http_err} - {response.text}") + raise + except requests.exceptions.RequestException as req_err: + logger.error(f"Request error during code exchange: {req_err}") + raise + except KeyError: + logger.error(f"Unexpected response format from token endpoint during code exchange: {response.text}") + raise ValueError("Could not parse tokens from code exchange response.") + + def get_access_token(self): + """ + Retrieves an access token. If a valid token exists, it's returned from cache. + Otherwise, a new one is requested using the refresh token. + """ + if self._is_token_valid(): + logger.info("Returning cached access token.") + return self._access_token + + if not self.refresh_token: + raise ValueError("Refresh token is not available. Please perform initial code exchange.") + + logger.info(f"Requesting new access token using refresh token from {self.token_endpoint}...") + + payload = { + 'grant_type': 'refresh_token', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'refresh_token': self.refresh_token + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + } + + try: + response = requests.post(self.token_endpoint, data=payload, headers=headers) + response.raise_for_status() + + token_data = response.json() + self._access_token = token_data['access_token'] + # Update refresh token if it's renewed (optional, but good practice) + if 'refresh_token' in token_data: + self.refresh_token = token_data['refresh_token'] + logger.info("Refresh token was renewed.") + self._token_expiry_time = time.time() + token_data['expires_in'] - 60 + + logger.info("Successfully obtained new access token via refresh token.") + return self._access_token + + except requests.exceptions.HTTPError as http_err: + logger.error(f"HTTP error occurred during refresh token use: {http_err} - {response.text}") + raise + except requests.exceptions.RequestException as req_err: + logger.error(f"Request error occurred during refresh token use: {req_err}") + raise + except KeyError: + logger.error(f"Unexpected response format from token endpoint: {response.text}") + raise ValueError("Could not parse access token from refresh token response.") diff --git a/connector-superoffice/explorer_client.py b/connector-superoffice/explorer_client.py new file mode 100644 index 00000000..99393ac6 --- /dev/null +++ b/connector-superoffice/explorer_client.py @@ -0,0 +1,23 @@ +""" +This module will be responsible for communicating with the Company Explorer API. +- Fetching companies that need enrichment. +- Pushing enriched data back. +""" + +class ExplorerClient: + def __init__(self, api_base_url, api_user, api_password): + self.api_base_url = api_base_url + self.auth = (api_user, api_password) + # TODO: Initialize requests session + + def get_companies_to_sync(self): + """ + Fetches a list of companies from the Company Explorer that are ready to be synced to SuperOffice. + """ + pass + + def get_company_details(self, company_id): + """ + Fetches detailed information for a single company. + """ + pass diff --git a/connector-superoffice/main.py b/connector-superoffice/main.py index acea9a6c..5f9d3c40 100644 --- a/connector-superoffice/main.py +++ b/connector-superoffice/main.py @@ -2,20 +2,71 @@ import os import logging from dotenv import load_dotenv -# Setup basic logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +# Import the new classes +from .auth_handler import AuthHandler +from .superoffice_client import SuperOfficeClient + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler() + ] +) logger = logging.getLogger(__name__) -# Load environment variables -load_dotenv() - def main(): - logger.info("Starting SuperOffice Connector...") + """ + Main function to initialize and run the SuperOffice connector. + """ + logger.info("Starting SuperOffice Connector Proof of Concept...") + + # Load environment variables from the root .env file + dotenv_path = os.path.join(os.path.dirname(__file__), '..', '.env') + logger.info(f"Attempting to load .env file from: {dotenv_path}") - # TODO: Implement authentication logic here using Gemini CLI - # TODO: Implement Polling / Sync logic here - - logger.info("Connector stopped.") + if os.path.exists(dotenv_path): + load_dotenv(dotenv_path=dotenv_path) + logger.info(".env file loaded successfully.") + else: + logger.error(f".env file not found at the expected location: {dotenv_path}") + logger.error("Please ensure the .env file exists in the project root directory.") + return + + try: + # 1. Initialize AuthHandler + auth_handler = AuthHandler() + + # Define the tenant ID + TENANT_ID = "Cust26720" # As provided in the initial task description + + # 2. Get a fresh access token (this will also update the refresh token if needed) + full_access_token = auth_handler.get_access_token() + logger.info(f"Full Access Token for curl: {full_access_token}") + + # 3. Initialize SuperOfficeClient with the tenant ID + so_client = SuperOfficeClient(auth_handler, TENANT_ID) + + # 3. Perform test connection + logger.info("--- Performing Connection Test ---") + connection_result = so_client.test_connection() + logger.info("--- Connection Test Finished ---") + + if connection_result: + logger.info("POC Succeeded: Connection to SuperOffice API was successful.") + else: + logger.error("POC Failed: Could not establish a connection to SuperOffice API.") + + except ValueError as ve: + logger.error(f"Configuration Error: {ve}") + logger.error("POC Failed due to missing configuration.") + except Exception as e: + logger.error(f"An unexpected error occurred during the POC execution: {e}", exc_info=True) + logger.error("POC Failed due to an unexpected error.") + + + logger.info("SuperOffice Connector POC finished.") if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/connector-superoffice/requirements.txt b/connector-superoffice/requirements.txt index 6e9be739..df7458c2 100644 --- a/connector-superoffice/requirements.txt +++ b/connector-superoffice/requirements.txt @@ -1,4 +1,2 @@ requests -pydantic python-dotenv -tenacity diff --git a/connector-superoffice/superoffice_client.py b/connector-superoffice/superoffice_client.py new file mode 100644 index 00000000..14655fd1 --- /dev/null +++ b/connector-superoffice/superoffice_client.py @@ -0,0 +1,55 @@ +import requests +import logging +from .auth_handler import AuthHandler + +logger = logging.getLogger(__name__) + +class SuperOfficeClient: + """ + A client for interacting with the SuperOffice API. + """ + def __init__(self, auth_handler: AuthHandler, tenant_id: str): + # Base URL for the SuperOffice REST API, including tenant_id + self.base_url = f"https://sod.superoffice.com/{tenant_id}/api" + self.auth_handler = auth_handler + self.tenant_id = tenant_id + self.session = requests.Session() + + def _get_auth_headers(self): + """Returns the authorization headers with a valid token and context.""" + access_token = self.auth_handler.get_access_token() + return { + 'Authorization': f'Bearer {access_token}', + 'Accept': 'application/json', + 'X-SuperOffice-Context': f'TenantId={self.tenant_id}' # Crucial for multi-tenant environments + } + + def test_connection(self): + """ + Performs a simple API call to test the connection and authentication. + Fetches the current user principal. + """ + endpoint = "/v1/User/currentPrincipal" + test_url = f"{self.base_url}{endpoint}" + + logger.info(f"Attempting to test connection to: {test_url}") + + try: + headers = self._get_auth_headers() + response = self.session.get(test_url, headers=headers) + response.raise_for_status() + + user_data = response.json() + logger.info("Successfully connected to SuperOffice API.") + logger.info(f"Authenticated as: {user_data.get('Name', 'N/A')} ({user_data.get('Associate', 'N/A')})") + return user_data + + except requests.exceptions.HTTPError as http_err: + logger.error(f"HTTP error during connection test: {http_err} - {http_err.response.text}") + return None + except requests.exceptions.RequestException as req_err: + logger.error(f"Request error during connection test: {req_err}") + return None + except Exception as e: + logger.error(f"An unexpected error occurred during connection test: {e}") + return None diff --git a/connector-superoffice/sync_engine.py b/connector-superoffice/sync_engine.py new file mode 100644 index 00000000..c8e26a85 --- /dev/null +++ b/connector-superoffice/sync_engine.py @@ -0,0 +1,22 @@ +""" +This module contains the core logic for the synchronization process. +- Fetches data from Company Explorer. +- Fetches data from SuperOffice. +- Performs deduplication checks. +- Creates or updates companies and contacts in SuperOffice. +""" + +class SyncEngine: + def __init__(self, superoffice_client, explorer_client): + self.so_client = superoffice_client + self.ex_client = explorer_client + + def run_sync(self): + """ + Executes a full synchronization run. + """ + # 1. Get companies from Explorer + # 2. For each company, check if it exists in SuperOffice + # 3. If not, create it + # 4. If yes, update it (or skip) + pass