From 30c5172ed0a4bb698203a22bdbd3961cdd3c9c48 Mon Sep 17 00:00:00 2001 From: Floke Date: Sat, 28 Feb 2026 18:06:12 +0000 Subject: [PATCH] [31588f42] feat: implement SuperOffice Sales API prototype and fix auth docs --- GEMINI.md | 23 +++ connector-superoffice/README.md | 23 +++ connector-superoffice/create_sale_test.py | 168 ++++++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 connector-superoffice/create_sale_test.py diff --git a/GEMINI.md b/GEMINI.md index 6f4c4755..c3edf956 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -249,6 +249,29 @@ The full list of 25 verticals with their internal SuperOffice IDs (List `udlist3 ### 4. Campaign Trigger Logic The `worker.py` (v1.8) now extracts the `campaign_tag` from `SuperOffice:23:DisplayText`. This tag is passed to the Company Explorer's provisioning API. If a matching entry exists in the `MarketingMatrix` for that tag, specific texts are used; otherwise, it falls back to the "standard" Kaltakquise texts. +### 5. SuperOffice Authentication (Critical Update Feb 28, 2026) + +**Problem:** Authentication failures ("Invalid refresh token" or "Invalid client_id") occurred because standard `load_dotenv()` did not override stale environment variables present in the shell process. + +**Solution:** Always use `load_dotenv(override=True)` in Python scripts to force loading the actual values from the `.env` file. + +**Correct Authentication Pattern (Python):** +```python +from dotenv import load_dotenv +import os + +# CRITICAL: override=True ensures we read from .env even if env vars are already set +load_dotenv(override=True) + +client_id = os.getenv("SO_CLIENT_ID") +# ... +``` + +**Known Working Config (Production):** +* **Environment:** `online3` +* **Tenant:** `Cust26720` +* **Token Logic:** The `AuthHandler` implementation in `health_check_so.py` is the reference standard. Avoid using legacy `superoffice_client.py` without verifying it uses `override=True`. + --- Here are the available functions: [ diff --git a/connector-superoffice/README.md b/connector-superoffice/README.md index 65e900d1..7616c35f 100644 --- a/connector-superoffice/README.md +++ b/connector-superoffice/README.md @@ -273,6 +273,29 @@ python3 connector-superoffice/create_email_test.py 193036 * `POST /Document`: Creates the email body and metadata. * `POST /Appointment`: Creates the activity record linked to the document. +### 17. Sales & Opportunities Implementation (Feb 28, 2026) + +We have successfully prototyped the creation of "Sales" (Opportunities) via the API. This allows the AI to not only enrich contacts but also create tangible pipeline objects for sales representatives. + +**Prototype Script:** `connector-superoffice/create_sale_test.py` + +**Key Findings:** +1. **Authentication:** Must use `load_dotenv(override=True)` to ensure production credentials are used (see GEMINI.md). +2. **Required Fields for `POST /Sale`:** + * `Heading`: The title of the opportunity. + * `Amount` & `Currency`: Use `Currency: { "Id": 33 }` (for EUR) instead of string "EUR". + * `SaleType`: `{ "Id": 1 }` (General Sale / Allgemeiner Verkauf). + * `Stage`: `{ "Id": 10 }` (5% Prospect / Angebot prospektiv). + * `Source`: `{ "Id": 2 }` (Proposal Center/Lead Source). + * `Contact`: `{ "ContactId": 123 }`. + * `Person`: `{ "PersonId": 456 }` (Optional but recommended). + +**Usage:** +```bash +python3 connector-superoffice/create_sale_test.py +``` +This script finds the first available contact in the CRM and creates a test opportunity for them. + --- This is the core logic used to generate the company-specific opener. diff --git a/connector-superoffice/create_sale_test.py b/connector-superoffice/create_sale_test.py new file mode 100644 index 00000000..be79f789 --- /dev/null +++ b/connector-superoffice/create_sale_test.py @@ -0,0 +1,168 @@ +import os +import sys +import json +import logging +import requests +from dotenv import load_dotenv + +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger("create_sale_test") + +# --- Inline AuthHandler & SuperOfficeClient (Proven Working Logic) --- + +class AuthHandler: + def __init__(self): + # CRITICAL: override=True ensures we read from .env even if env vars are already set + load_dotenv(override=True) + + self.client_id = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD") + self.client_secret = os.getenv("SO_CLIENT_SECRET") + self.refresh_token = os.getenv("SO_REFRESH_TOKEN") + self.redirect_uri = os.getenv("SO_REDIRECT_URI", "http://localhost") + self.env = os.getenv("SO_ENVIRONMENT", "sod") + self.cust_id = os.getenv("SO_CONTEXT_IDENTIFIER", "Cust55774") + + if not all([self.client_id, self.client_secret, self.refresh_token]): + raise ValueError("SuperOffice credentials missing in .env file.") + + def get_access_token(self): + return self._refresh_access_token() + + def _refresh_access_token(self): + token_domain = "online.superoffice.com" if "online" in self.env.lower() else "sod.superoffice.com" + url = f"https://{token_domain}/login/common/oauth/tokens" + + data = { + "grant_type": "refresh_token", + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": self.refresh_token, + "redirect_uri": self.redirect_uri + } + try: + resp = requests.post(url, data=data) + if resp.status_code != 200: + logger.error(f"❌ Token Refresh Failed (Status {resp.status_code}): {resp.text}") + return None + return resp.json().get("access_token") + except Exception as e: + logger.error(f"❌ Connection Error during token refresh: {e}") + return None + +class SuperOfficeClient: + def __init__(self, auth_handler): + self.auth_handler = auth_handler + self.env = auth_handler.env + self.cust_id = auth_handler.cust_id + self.base_url = f"https://{self.env}.superoffice.com/{self.cust_id}/api/v1" + self.access_token = self.auth_handler.get_access_token() + + if not self.access_token: + raise Exception("Failed to obtain access token.") + + self.headers = { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + "Accept": "application/json" + } + + def _get(self, endpoint): + resp = requests.get(f"{self.base_url}/{endpoint}", headers=self.headers) + resp.raise_for_status() + return resp.json() + + def _post(self, endpoint, payload): + resp = requests.post(f"{self.base_url}/{endpoint}", headers=self.headers, json=payload) + resp.raise_for_status() + return resp.json() + +def main(): + try: + # Initialize Auth and Client + auth = AuthHandler() + client = SuperOfficeClient(auth) + + print("\n--- 0. Pre-Flight: Finding Currency ID for EUR ---") + currencies = client._get("List/Currency/Items") + eur_id = None + for curr in currencies: + if curr.get('Name') == 'EUR' or curr.get('value') == 'EUR': # Check keys carefully + eur_id = curr.get('Id') + print(f"✅ Found EUR Currency ID: {eur_id}") + break + + if not eur_id: + print("⚠️ EUR Currency not found. Defaulting to ID 33 (from discovery).") + eur_id = 33 + + print("\n--- 1. Finding a Target Contact (Company) ---") + contacts = client._get("Contact?$top=1&$select=ContactId,Name") + + if not contacts or 'value' not in contacts or len(contacts['value']) == 0: + logger.error("No contacts found in CRM. Cannot create sale.") + return + + target_contact = contacts['value'][0] + # API returns lowercase keys in this OData format + contact_id = target_contact.get('contactId') or target_contact.get('ContactId') + contact_name = target_contact.get('name') or target_contact.get('Name') + print(f"✅ Found Target Contact: {contact_name} (ID: {contact_id})") + + + print("\n--- 2. Finding a Person (Optional but recommended) ---") + persons_endpoint = f"Contact/{contact_id}/Persons?$top=1&$select=PersonId,FirstName,LastName" + persons = client._get(persons_endpoint) + + person_id = None + if persons and 'value' in persons and len(persons['value']) > 0: + target_person = persons['value'][0] + # Fix keys here too + person_id = target_person.get('personId') or target_person.get('PersonId') + first_name = target_person.get('firstName') or target_person.get('FirstName') + last_name = target_person.get('lastName') or target_person.get('LastName') + print(f"✅ Found Target Person: {first_name} {last_name} (ID: {person_id})") + else: + print("⚠️ No person found for this contact. Creating sale without person link.") + + print("\n--- 3. Creating Sale (Opportunity) ---") + + # Construct the payload for a new Sale + sale_payload = { + "Heading": "AI Prospect: Automation Potential Detected", + "Description": "Automated opportunity created by Gemini AI based on high automation potential score.\n\nKey Insights:\n- High robot density potential\n- Manual processes identified", + "Amount": 5000.0, + # FIXED: Use Object with ID for Currency + "Currency": { "Id": eur_id }, + "SaleType": { "Id": 1 }, # Allgemeiner Verkauf + "Stage": { "Id": 10 }, # 5% Angebot prospektiv + "Contact": { "ContactId": contact_id }, + "Source": { "Id": 2 } # Proposal Center + } + + if person_id: + sale_payload["Person"] = { "PersonId": person_id } + + print(f"Payload Preview: {json.dumps(sale_payload, indent=2)}") + + # Send POST request to create the Sale + new_sale = client._post("Sale", sale_payload) + + print("\n--- ✅ SUCCESS: Sale Created! ---") + sale_id = new_sale.get('SaleId') + sale_number = new_sale.get('SaleNumber') + print(f"Sale ID: {sale_id}") + print(f"Sale Number: {sale_number}") + + sale_link = f"https://{auth.env}.superoffice.com/{auth.cust_id}/default.aspx?sale_id={sale_id}" + print(f"Direct Link: {sale_link}") + + except requests.exceptions.HTTPError as e: + logger.error(f"❌ API Error: {e}") + if e.response is not None: + logger.error(f"Response Body: {e.response.text}") + except Exception as e: + logger.error(f"Fatal Error: {e}") + +if __name__ == "__main__": + main()