[30388f42] Infrastructure Hardening: Repaired CE/Connector DB schema, fixed frontend styling build, implemented robust echo shield in worker v2.1.1, and integrated Lead Engine into gateway.

This commit is contained in:
2026-03-07 14:08:42 +00:00
parent efcaa57cf0
commit ae2303b733
404 changed files with 24100 additions and 13301 deletions

View File

@@ -0,0 +1,25 @@
# Status Report: Email Sending Workaround (Feb 28, 2026)
## Problem
The automated dispatch of emails via the SuperOffice API (using `/Shipment` or `/Mailing` endpoints) is currently blocked by a **500 Internal Server Error** in the `Cust26720` tenant environment. Additionally, created Documents often throw a "Cannot check out" error when users try to open them directly, likely due to missing Web Tools or strict SharePoint integration policies for API-generated files.
## Solution / Workaround
We have implemented a robust "Activity-Based" workaround that ensures the email content is visible and actionable for the user.
1. **Draft Creation:** The system creates a Document (Template: "Ausg. E-Mail") via API.
2. **Content Upload:** The email body is uploaded as a binary stream to prevent "0kb file" errors.
3. **Activity Mirroring:** Crucially, a linked **Appointment (Task)** is created. The full email body is copied into the `Description` field of this appointment.
4. **Direct Access:** The user is provided with a direct link to the **Appointment**, bypassing the problematic Document checkout process.
## Verification
* **Target:** Person ID 193036 (Christian Test2 / floke.com@gmail.com)
* **Document ID:** 334055 (Content uploaded successfully)
* **Activity ID:** 992236 (Contains full text)
* **Result:** The user can open the Activity link, copy the pre-generated text, and send it via their standard mail client or SuperOffice MailLink.
## Usage
Run the test script to generate a new draft for any person:
```bash
python3 connector-superoffice/create_email_test.py <PersonID>
```
The script outputs the "Safe Link" to the Activity.

View File

@@ -1,90 +1,64 @@
# SuperOffice Connector ("The Muscle") - GTM Engine
# SuperOffice Connector README
Dies ist der "dumme" Microservice zur Anbindung von **SuperOffice CRM** an die **Company Explorer Intelligence**.
Der Connector agiert als reiner Bote ("Muscle"): Er nimmt Webhook-Events entgegen, fragt das "Gehirn" (Company Explorer) nach Instruktionen und führt diese im CRM aus.
## Overview
This directory contains Python scripts designed to integrate with the SuperOffice CRM API, primarily for data enrichment and lead generation automation.
## 1. Architektur: "The Intelligent Hub & The Loyal Messenger"
## 🚀 Production Deployment (March 2026)
Wir haben uns für eine **Event-gesteuerte Architektur** entschieden, um Skalierbarkeit und Echtzeit-Verarbeitung zu gewährleisten.
**Status:** ✅ Live & Operational
**Environment:** `online3` (Production)
**Tenant:** `Cust26720`
**Der Datenfluss:**
1. **Auslöser:** User ändert in SuperOffice einen Kontakt (z.B. Status -> `Init`).
2. **Transport:** SuperOffice sendet ein `POST` Event an unseren Webhook-Endpunkt (`:8003/webhook`).
3. **Queueing:** Der `Webhook Receiver` validiert das Event und legt es sofort in eine lokale `SQLite`-Queue (`connector_queue.db`).
4. **Verarbeitung:** Ein separater `Worker`-Prozess holt den Job ab.
5. **Provisioning:** Der Worker fragt den **Company Explorer** (`POST /api/provision/superoffice-contact`): "Was soll ich mit Person ID 123 tun?".
6. **Write-Back:** Der Company Explorer liefert das fertige Text-Paket (Subject, Intro, Proof) zurück. Der Worker schreibt dies via REST API in die UDF-Felder von SuperOffice.
### 1. Architecture & Flow
1. **Trigger:** SuperOffice sends a webhook (`contact.created`, `contact.changed`, `person.created`, `person.changed`) to `https://floke-ai.duckdns.org/connector/webhook`.
2. **Reception:** `webhook_app.py` (FastAPI) receives the event, validates the `WEBHOOK_TOKEN`, and pushes a job to the SQLite queue (`connector_queue.db`).
3. **Processing:** `worker.py` (v1.9.1) polls the queue, filters for relevance, fetches details from SuperOffice, and calls the **Company Explorer** for AI analysis.
4. **Sync:** Results (Vertical, Summary, hyper-personalized Openers) are patched back into SuperOffice `UserDefinedFields`.
## 2. Kern-Komponenten
### 2. Operational Resilience (Lessons Learned)
* **`webhook_app.py` (FastAPI):**
* Lauscht auf Port `8000` (Extern: `8003`).
* Nimmt Events entgegen, prüft Token (`WEBHOOK_SECRET`).
* Schreibt Jobs in die Queue.
* Endpunkt: `POST /webhook`.
#### 🛡️ A. Noise Reduction & Loop Prevention
* **Problem:** Every data update (`PATCH`) triggers a new `contact.changed` webhook, creating infinite loops (Ping-Pong effect).
* **Solution 1 (Whitelist):** Only changes to `name`, `urladdress`, `urls`, `orgnr`, or `userdef_id` (UDFs) trigger processing.
* **Solution 2 (Circuit Breaker):** The system explicitly ignores any events triggered by its own API User (**Associate ID 528**). This stops the echo effect immediately.
* **`queue_manager.py` (SQLite):**
* Verwaltet die lokale Job-Queue.
* Status: `PENDING` -> `PROCESSING` -> `COMPLETED` / `FAILED`.
* Persistiert Jobs auch bei Container-Neustart.
#### 👥 B. Multi-Tenant Filtering (Roboplanet vs. Wackler)
* **Challenge:** The SuperOffice tenant is shared with Wackler. We must only process Roboplanet accounts.
* **Solution:** **Hybrid Whitelist Filtering**. We maintain a list of Roboplanet Associate IDs and Shortnames (e.g., `485`, `528`, `RKAB`, `RCGO`) in `config.py`.
* **Execution:** The worker fetches contact details and skips any account whose owner is not in this whitelist. This is faster and more reliable than querying group memberships via the API (which often returns 500 errors).
* **`worker.py`:**
* Läuft als Hintergrundprozess.
* Pollt die Queue alle 5 Sekunden.
* Kommuniziert mit Company Explorer (Intern: `http://company-explorer:8000`) und SuperOffice API.
* Behandelt Fehler und Retries.
#### 📊 C. Dashboard Persistence & Prioritization
* **Challenge:** Webhooks often only contain IDs, leaving the dashboard with "Entity C123".
* **Solution:** **Late Name Resolution**. The worker persists the resolved Company Name and Associate Shortname to the SQLite database as soon as it fetches them from SuperOffice.
* **Status Priority:** Success (`COMPLETED`) now "outshines" subsequent ignored echos (`SKIPPED`). Once an account is green, it stays green in the dashboard cluster for 15 minutes.
* **`superoffice_client.py`:**
* Kapselt die SuperOffice REST API (Auth, GET, PUT).
* Verwaltet Refresh Tokens.
### 3. Advanced API Handling (Critical Fixes)
## 3. Setup & Konfiguration
* **OData Pagination:** We implemented `odata.nextLink` support (Manuel Zierl's advice) to correctly handle large result sets (>1000 records).
* **Case Sensitivity:** We learned that SuperOffice API keys are case-sensitive in responses (e.g., `contactId` vs `ContactId`) depending on the endpoint. Our code now handles both.
* **Auth Consistency:** Always use `load_dotenv(override=True)` to prevent stale environment variables (like expired tokens) from lingering in the shell process.
* **Identity Crisis:** The `Associate/Me` and `Associate/{id}` endpoints return 500 errors if the API user lacks a linked Person record. This is a known configuration blocker for automated mailing.
### Docker Service
Der Service läuft im Container `connector-superoffice`.
Startet via `start.sh` sowohl den Webserver als auch den Worker.
### 4. Docker Optimization
### Konfiguration (`.env`)
Der Connector benötigt folgende Variablen (in `docker-compose.yml` gesetzt):
* **Multi-Stage builds:** Reduced build time from 8+ minutes to seconds (for code changes) by separating the build environment (compilers) from the runtime environment.
* **Size reduction:** Removed `build-essential` and Node.js runtimes from final images, drastically shrinking the footprint.
```yaml
environment:
API_USER: "admin"
API_PASSWORD: "..."
COMPANY_EXPLORER_URL: "http://company-explorer:8000" # Interne Docker-Adresse
WEBHOOK_SECRET: "changeme" # Muss mit SO-Webhook Config übereinstimmen
# Plus die SuperOffice Credentials (Client ID, Secret, Refresh Token)
```
### 5. Tooling & Diagnosis
Located in `connector-superoffice/tools/`:
## 4. API-Schnittstelle (Intern)
* `verify_latest_roboplanet.py`: The "Ocular Proof" script that finds the youngest target account.
* `check_filter_counts.py`: Compares API counts with UI counts.
* `find_latest_roboplanet_account.py`: Diagnostic tool for group filtering.
* `cleanup_test_data.py`: Safely removes all session-created test objects.
Der Connector ruft den Company Explorer auf und liefert dabei **Live-Daten** aus dem CRM für den "Double Truth" Abgleich:
### 6. Open Todos
* [x] **List ID Mapping:** Identified Production IDs (1613-1637). Hardcoded in `config.py`.
* [ ] **Mailing Identity:** Resolve 500 error on `Associate/Me`. (Meeting scheduled for Monday).
* [x] **Docker Build:** Multi-stage builds implemented.
* [x] **Dashboard:** Names and Associate shortnames now visible.
**Request:** `POST /api/provision/superoffice-contact`
```json
{
"so_contact_id": 12345,
"so_person_id": 67890,
"crm_name": "RoboPlanet GmbH",
"crm_website": "www.roboplanet.de",
"job_title": "Geschäftsführer"
}
```
---
**Response:**
```json
{
"status": "success",
"texts": {
"subject": "Optimierung Ihrer Logistik...",
"intro": "Als Logistikleiter kennen Sie...",
"social_proof": "Wir helfen bereits Firma X..."
}
}
```
## 5. Offene To-Dos (Roadmap)
* [ ] **UDF-Mapping:** Aktuell sind die `ProgId`s (z.B. `SuperOffice:5`) im Code (`worker.py`) hartkodiert. Dies muss in eine Config ausgelagert werden.
* [ ] **Fehlerbehandlung:** Was passiert, wenn der Company Explorer "404 Not Found" meldet? (Aktuell: Log Warning & Skip).
* [ ] **Redis:** Bei sehr hoher Last (>100 Events/Sekunde) sollte die SQLite-Queue durch Redis ersetzt werden.
## Authentication (Legacy)
Authentication is handled via the `AuthHandler` class, which uses a refresh token flow to obtain access tokens. Ensure that the `.env` file in the project root is correctly configured. ALWAYS use `override=True` when loading settings.

View File

@@ -0,0 +1,37 @@
# SuperOffice Email Sending Strategy Analysis (Feb 28, 2026)
## Executive Summary
Automated email sending "on behalf of" sales representatives directly via the SuperOffice API is currently **technically blocked** due to missing permissions and license restrictions on the configured System User (Client ID `0fd8...`).
We have exhausted all standard API paths (Agents, REST, Archive, CRMScript). A strategic reconfiguration by the SuperOffice administrator is required.
## Technical Findings
| Feature | Status | Error Code | Root Cause Analysis |
| :--- | :--- | :--- | :--- |
| **Document Creation** | ✅ Working | 200 OK | We can create `.somail` files in the archive. |
| **Native Sending** (`/Shipment`) | ❌ Failed | 500 Internal | The System User lacks a valid `Associate` context or "Mailing" license. |
| **Agent Sending** (`/Agents/EMail`) | ❌ Failed | 401 Unauth | The standard OAuth token is rejected for this Agent; likely requires "interactive" user context or specific scope. |
| **CRMScripting** | ❌ Failed | 403 Forbidden | Access to the Scripting Engine is blocked for this API user. |
| **User Context** (`/Associate/Me`) | ❌ Failed | 500 Internal | **Critical:** The System User does not know "who it is". This breaks all "Send As" logic. |
## Required Actions (IT / Admin)
To enable automated sending, one of the following two paths must be implemented:
### Option A: Enable Native SuperOffice Sending (Preferred)
1. **Fix System User:** The API User must be linked to a valid "Person" card in SuperOffice Admin with **Service / Marketing Administrator** rights.
2. **Enable Mailings:** The tenant `Cust26720` must have the "Marketing" license active and assigned to the API User.
3. **Approve "Send As":** The API User needs explicit permission to set the `SenderEmailAddress` field in Shipments.
### Option B: External Sending Engine (Recommended Fallback)
If Option A is too complex or costly (licensing), we switch the architecture:
1. **SMTP Relay:** Provision a dedicated SMTP account (e.g., Office365 Service Account or SendGrid) for the "RoboPlanet GTM Engine".
2. **Logic Shift:** The Python Connector sends the email via SMTP (Python `smtplib`).
3. **Archiving:** The Connector saves the *sent* email as a `.eml` document in SuperOffice (which already works!).
## Immediate Workaround
Until a decision is made, the system uses the **"Activity Handoff"** method:
1. System generates the text.
2. System creates a Task (Appointment) in SuperOffice.
3. User clicks a link, copies the text, and sends via their own Outlook/Gmail.

View File

@@ -0,0 +1,38 @@
import os
import json
import logging
import sys
from dotenv import load_dotenv
load_dotenv(override=True)
from superoffice_client import SuperOfficeClient
logging.basicConfig(level=logging.INFO)
sys.stdout.reconfigure(line_buffering=True)
def attempt_send(to_email: str):
client = SuperOfficeClient()
# Payload for Agents/EMail/Send
# It expects an array of "EMail" objects
payload = [
{
"To": [{"Value": to_email, "Address": to_email}],
"Subject": "Test from SuperOffice Agent API",
"HTMLBody": "<h1>Hello!</h1><p>This is a test from the Agents/EMail/Send endpoint.</p>",
"From": {"Value": "system@roboplanet.de", "Address": "system@roboplanet.de"} # Try to force a sender
}
]
print(f"🚀 Attempting POST /Agents/EMail/Send to {to_email}...")
try:
# Note: The endpoint might be v1/Agents/EMail/Send
res = client._post("Agents/EMail/Send", payload)
if res:
print("✅ Success! Response:", json.dumps(res, indent=2))
else:
print("❌ Request failed (None returned).")
except Exception as e:
print(f"❌ Exception during send: {e}")
if __name__ == "__main__":
attempt_send("floke.com@gmail.com")

View File

@@ -0,0 +1,36 @@
import os
import json
import logging
import sys
from dotenv import load_dotenv
load_dotenv(override=True)
from superoffice_client import SuperOfficeClient
logging.basicConfig(level=logging.INFO)
sys.stdout.reconfigure(line_buffering=True)
def check_crmscript():
client = SuperOfficeClient()
print(f"🚀 Checking CRMScript capability...")
# 1. Check if we can list scripts
try:
# Agents/CRMScript/GetCRMScripts
res = client._post("Agents/CRMScript/GetCRMScripts", payload={"CRMScriptIds": []}) # Empty array usually gets all or error
if res:
print(f"✅ Can access CRMScripts. Response type: {type(res)}")
else:
# Try GET Archive
print("⚠️ Agent access failed/empty. Trying Archive...")
res = client._get("Archive/dynamic?$select=all&$top=1&entity=crmscript")
if res:
print(f"✅ CRMScript Entity found in Archive.")
else:
print(f"❌ CRMScript Entity NOT found in Archive.")
except Exception as e:
print(f"❌ Exception checking CRMScript: {e}")
if __name__ == "__main__":
check_crmscript()

View File

@@ -1,45 +1,66 @@
import os
from dotenv import load_dotenv
# Load environment variables
if os.path.exists(".env"):
load_dotenv(".env", override=True)
elif os.path.exists("../.env"):
load_dotenv("../.env", override=True)
# Explicitly load .env from the project root.
# CRITICAL: override=True ensures we read from the .env file even if
# stale env vars are present in the shell process.
dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '.env'))
if os.path.exists(dotenv_path):
load_dotenv(dotenv_path=dotenv_path, override=True)
class Config:
# SuperOffice API Configuration
SO_CLIENT_ID = os.getenv("SO_SOD")
SO_CLIENT_SECRET = os.getenv("SO_CLIENT_SECRET")
SO_CONTEXT_IDENTIFIER = os.getenv("SO_CONTEXT_IDENTIFIER")
SO_REFRESH_TOKEN = os.getenv("SO_REFRESH_TOKEN")
# Company Explorer Configuration
CE_API_URL = os.getenv("CE_API_URL", "http://company-explorer:8000")
CE_API_USER = os.getenv("CE_API_USER", "admin")
CE_API_PASSWORD = os.getenv("CE_API_PASSWORD", "gemini")
class Settings:
def __init__(self):
# --- Infrastructure ---
# Internal Docker URL for Company Explorer
self.COMPANY_EXPLORER_URL = os.getenv("COMPANY_EXPLORER_URL", "http://company-explorer:8000")
# --- SuperOffice API Credentials ---
# Fallback for empty string in env var
env_val = os.getenv("SO_ENVIRONMENT")
self.SO_ENVIRONMENT = env_val if env_val else "online3"
self.SO_CLIENT_ID = os.getenv("SO_CLIENT_ID", "")
self.SO_CLIENT_SECRET = os.getenv("SO_CLIENT_SECRET", "")
self.SO_REFRESH_TOKEN = os.getenv("SO_REFRESH_TOKEN", "")
self.SO_REDIRECT_URI = os.getenv("SO_REDIRECT_URI", "http://localhost")
self.SO_CONTEXT_IDENTIFIER = os.getenv("SO_CONTEXT_IDENTIFIER", "Cust55774") # e.g. Cust12345
# --- Feature Flags ---
self.ENABLE_WEBSITE_SYNC = os.getenv("ENABLE_WEBSITE_SYNC", "False").lower() in ("true", "1", "t")
# Mappings (IDs from SuperOffice)
# Vertical IDs (List Items)
self.VERTICAL_MAP_JSON = os.getenv("VERTICAL_MAP_JSON", '{"Automotive - Dealer": 1613, "Corporate - Campus": 1614, "Energy - Grid & Utilities": 1615, "Energy - Solar/Wind": 1616, "Healthcare - Care Home": 1617, "Healthcare - Hospital": 1618, "Hospitality - Gastronomy": 1619, "Hospitality - Hotel": 1620, "Industry - Manufacturing": 1621, "Infrastructure - Communities": 1622, "Infrastructure - Public": 1623, "Infrastructure - Transport": 1624, "Infrastructure - Parking": 1625, "Leisure - Entertainment": 1626, "Leisure - Fitness": 1627, "Leisure - Indoor Active": 1628, "Leisure - Outdoor Park": 1629, "Leisure - Wet & Spa": 1630, "Logistics - Warehouse": 1631, "Others": 1632, "Reinigungsdienstleister": 1633, "Retail - Food": 1634, "Retail - Non-Food": 1635, "Retail - Shopping Center": 1636, "Tech - Data Center": 1637}')
# UDF Mapping (ProgIds) - Defaulting to SOD values, should be overridden in Prod
UDF_CONTACT_MAPPING = {
"ai_challenge_sentence": os.getenv("UDF_CONTACT_CHALLENGE", "SuperOffice:1"),
"ai_sentence_timestamp": os.getenv("UDF_CONTACT_TIMESTAMP", "SuperOffice:2"),
"ai_sentence_source_hash": os.getenv("UDF_CONTACT_HASH", "SuperOffice:3"),
"ai_last_outreach_date": os.getenv("UDF_CONTACT_OUTREACH", "SuperOffice:4")
}
# Persona / Job Role IDs (List Items for "Position" field)
self.PERSONA_MAP_JSON = os.getenv("PERSONA_MAP_JSON", '{}')
UDF_PERSON_MAPPING = {
"ai_email_draft": os.getenv("UDF_PERSON_DRAFT", "SuperOffice:1"),
"ma_status": os.getenv("UDF_PERSON_STATUS", "SuperOffice:2")
}
# User Defined Fields (ProgIDs)
self.UDF_SUBJECT = os.getenv("UDF_SUBJECT", "SuperOffice:5")
self.UDF_INTRO = os.getenv("UDF_INTRO", "SuperOffice:6")
self.UDF_SOCIAL_PROOF = os.getenv("UDF_SOCIAL_PROOF", "SuperOffice:7")
self.UDF_VERTICAL = os.getenv("UDF_VERTICAL", "SuperOffice:5")
self.UDF_OPENER = os.getenv("UDF_OPENER", "SuperOffice:6")
self.UDF_OPENER_SECONDARY = os.getenv("UDF_OPENER_SECONDARY", "SuperOffice:7")
self.UDF_CAMPAIGN = os.getenv("UDF_CAMPAIGN", "SuperOffice:23") # Default from discovery
self.UDF_UNSUBSCRIBE_LINK = os.getenv("UDF_UNSUBSCRIBE_LINK", "SuperOffice:22")
self.UDF_SUMMARY = os.getenv("UDF_SUMMARY", "SuperOffice:84")
self.UDF_LAST_UPDATE = os.getenv("UDF_LAST_UPDATE", "SuperOffice:85")
self.UDF_LAST_OUTREACH = os.getenv("UDF_LAST_OUTREACH", "SuperOffice:88")
# MA Status ID Mapping (Text -> ID) - Defaulting to discovered SOD values
MA_STATUS_ID_MAP = {
"Ready_to_Send": int(os.getenv("MA_STATUS_ID_READY", 11)),
"Sent_Week1": int(os.getenv("MA_STATUS_ID_WEEK1", 12)),
"Sent_Week2": int(os.getenv("MA_STATUS_ID_WEEK2", 13)),
"Bounced": int(os.getenv("MA_STATUS_ID_BOUNCED", 14)),
"Soft_Denied": int(os.getenv("MA_STATUS_ID_DENIED", 15)),
"Interested": int(os.getenv("MA_STATUS_ID_INTERESTED", 16)),
"Out_of_Office": int(os.getenv("MA_STATUS_ID_OOO", 17)),
"Unsubscribed": int(os.getenv("MA_STATUS_ID_UNSUB", 18))
}
# --- User Whitelist (Roboplanet Associates) ---
# Includes both Numerical IDs and Shortnames for robustness
self.ROBOPLANET_WHITELIST = {
# IDs
485, 454, 487, 515, 469, 528, 512, 465, 486, 493, 468, 476, 455, 483,
492, 523, 470, 457, 498, 491, 464, 525, 527, 496, 490, 497, 456, 479,
# Shortnames
"RAAH", "RIAK", "RABA", "RJBU", "RPDU", "RCGO", "RBHA", "RAHE", "RPHO",
"RSHO", "RMJO", "DKE", "RAKI", "RSKO", "RMKR", "RSLU", "REME", "RNSL",
"RAPF", "ROBO", "RBRU", "RSSC", "RBSC", "RASC", "RKAB", "RDSE", "RSSH",
"RJST", "JUTH", "RSWA", "RCWE", "RJZH", "EVZ"
}
# Global instance
settings = Settings()

View File

@@ -0,0 +1,173 @@
import os
import requests
import json
import logging
import argparse
from dotenv import load_dotenv
load_dotenv(override=True)
from superoffice_client import SuperOfficeClient
from config import settings
# Setup Logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("create-email-test")
def create_email_document(person_id_input: int):
print(f"🚀 Creating Email Document for Person ID {person_id_input}...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed. Check .env")
return
# --- TARGET PERSON ---
target_person_id = person_id_input
contact_id = None
person_id = None
print(f"📡 Fetching target Person {target_person_id}...")
try:
person = client._get(f"Person/{target_person_id}")
if not person:
print(f"❌ Person {target_person_id} not found.")
return
print(f"✅ Found Person: {person.get('Firstname')} {person.get('Lastname')}")
# Get associated Contact ID
contact_id = person.get('Contact', {}).get('ContactId')
if not contact_id:
print("❌ Person has no associated company (ContactId).")
return
# Verify Contact
contact = client._get(f"Contact/{contact_id}")
if contact:
print(f"✅ Associated Company: {contact.get('Name')} (ID: {contact_id})")
person_id = target_person_id
except Exception as e:
print(f"❌ Error fetching Person/Contact: {e}")
return
if not contact_id or not person_id:
print("❌ Could not resolve Contact/Person IDs.")
return
# 2. Define Email Content
# Get Email Address from Person
email_address = person.get("Emails", [{}])[0].get("Value", "k.A.")
subject = f"Optimierung Ihrer Service-Prozesse (Referenz: {person.get('Firstname')} {person.get('Lastname')})"
# We use the UDFs we already found in Person 193036
udefs = person.get("UserDefinedFields", {})
intro = udefs.get(settings.UDF_INTRO, "Guten Tag,")
proof = udefs.get(settings.UDF_SOCIAL_PROOF, "Wir unterstützen Unternehmen bei der Automatisierung.")
unsub = udefs.get(settings.UDF_UNSUBSCRIBE_LINK, "")
body = f"""{intro}
{proof}
Abmelden: {unsub}
Viele Grüße,
Christian Godelmann
RoboPlanet"""
# 3. Create Document Payload
template_id = 157
payload = {
"Name": f"Outreach: {email_address}", # Internal Name with Email for visibility
"Header": subject, # Subject Line
"Contact": {"ContactId": contact_id},
"Person": {"PersonId": person_id},
"DocumentTemplate": {"DocumentTemplateId": template_id},
"Content": body
}
print(f"📤 Creating E-Mail draft for {email_address}...")
try:
doc = client._post("Document", payload)
if doc:
doc_id = doc.get('DocumentId')
print(f"✅ Document Created Successfully!")
print(f" ID: {doc_id}")
print(f" Recipient: {email_address}")
print(f" Template: {doc.get('DocumentTemplate', {}).get('Name')}")
# 3b. Upload Content (Critical Step to avoid 'Checkout Error')
print(f"📤 Uploading content stream to Document {doc_id}...")
try:
content_bytes = body.encode('utf-8')
# Manual request because _request_with_retry assumes JSON
headers = client.headers.copy()
headers["Content-Type"] = "application/octet-stream"
res = requests.put(
f"{client.base_url}/Document/{doc_id}/Content",
data=content_bytes,
headers=headers
)
if res.status_code in [200, 204]:
print("✅ Content uploaded successfully.")
else:
print(f"⚠️ Content upload failed: {res.status_code} {res.text}")
except Exception as e:
print(f"⚠️ Content upload error: {e}")
# Construct direct link
env = settings.SO_ENVIRONMENT
cust_id = settings.SO_CONTEXT_IDENTIFIER
doc_link = f"https://{env}.superoffice.com/{cust_id}/default.aspx?document_id={doc_id}"
# 4. Create Linked Appointment (Activity)
print("📅 Creating Linked Appointment (Email Sent Activity)...")
appt_payload = {
"Description": body,
"Contact": {"ContactId": contact_id},
"Person": {"PersonId": person_id},
"Task": {"Id": 6}, # 6 = Document / Email Out
"Document": {"DocumentId": doc_id},
"MainHeader": f"E-Mail: {subject}"[:40]
}
try:
appt = client._post("Appointment", appt_payload)
if appt:
appt_id = appt.get('AppointmentId')
print(f"✅ Appointment Created: {appt_id}")
appt_link = f"https://{env}.superoffice.com/{cust_id}/default.aspx?appointment_id={appt_id}"
print(f"\n--- WICHTIG: NUTZEN SIE DIESEN LINK ---")
print(f"Da das Dokument selbst ('Cannot check out') oft blockiert,")
print(f"öffnen Sie bitte die AKTIVITÄT. Dort steht der Text im Beschreibungsfeld:")
print(f"🔗 {appt_link}")
print(f"---------------------------------------\n")
print(f"(Backup Link zum Dokument: {doc_link})")
else:
print("⚠️ Failed to create appointment (None response).")
except Exception as e:
print(f"⚠️ Failed to create appointment: {e}")
else:
print("❌ Failed to create document (Response was empty/None).")
except Exception as e:
print(f"❌ Error creating document: {e}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Create a test email document in SuperOffice.')
parser.add_argument('person_id', type=int, help='The SuperOffice Person ID to attach the email to.')
args = parser.parse_args()
create_email_document(args.person_id)

View File

@@ -0,0 +1,71 @@
import os
import requests
import json
import logging
from dotenv import load_dotenv
load_dotenv(override=True)
from superoffice_client import SuperOfficeClient
from config import settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("mailing-test")
def create_mailing(person_id: int):
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# 1. Get Person & Marketing Texts
person = client._get(f"Person/{person_id}")
if not person:
print(f"❌ Person {person_id} not found.")
return
email_address = person.get("Emails", [{}])[0].get("Value")
if not email_address:
print(f"❌ Person {person_id} has no email address.")
return
udefs = person.get("UserDefinedFields", {})
subject = udefs.get(settings.UDF_SUBJECT)
intro = udefs.get(settings.UDF_INTRO)
proof = udefs.get(settings.UDF_SOCIAL_PROOF)
if not all([subject, intro, proof]):
print("❌ Marketing texts missing in Person UDFs. Run provisioning first.")
return
full_body = f"{intro}\n\n{proof}\n\nAbmelden: {udefs.get(settings.UDF_UNSUBSCRIBE_LINK)}"
# 2. Create a "Shipment" (Individual Mailing)
# Based on SO Documentation for Marketing API
# We try to create a Shipment directly.
payload = {
"Name": f"Gemini Outreach: {subject}",
"Subject": subject,
"Body": full_body,
"DocumentTemplateId": 157, # Ausg. E-Mail
"ShipmentType": "Email",
"AssociateId": 528, # RCGO
"ContactId": person.get("Contact", {}).get("ContactId"),
"PersonId": person_id,
"Status": "Ready" # This might trigger the internal SO send process
}
print(f"📤 Creating Shipment for {email_address}...")
try:
# Endpoints to try: /Shipment or /Mailing
# Let's try /Shipment
resp = client._post("Shipment", payload)
if resp:
print(f"✅ Shipment created successfully! ID: {resp.get('ShipmentId')}")
print(json.dumps(resp, indent=2))
else:
print("❌ Shipment creation returned no data.")
except Exception as e:
print(f"❌ Shipment API failed: {e}")
if __name__ == "__main__":
create_mailing(193036)

View File

@@ -0,0 +1,180 @@
import os
import sys
import json
import logging
import requests
from datetime import datetime, timedelta
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) ---")
# Search for "Test" to avoid hitting the Wackler parent company (ID 3)
# contacts = client._get("Contact?$top=1&$filter=name contains 'Test'&$select=ContactId,Name")
# Fallback if no test company found, but warn user
# if not contacts or 'value' not in contacts or len(contacts['value']) == 0:
# print("⚠️ No company with 'Test' found. Please create a test company first.")
# return
# target_contact = contacts['value'][0]
# contact_id = target_contact.get('contactId') or target_contact.get('ContactId')
# contact_name = target_contact.get('name') or target_contact.get('Name')
contact_id = 171185
contact_name = "Bremer Abenteuerland"
# SAFEGUARD: Do not post to Wackler Service Group (ID 3)
if int(contact_id) == 3:
logger.error("⛔ ABORTING: Target company is Wackler Service Group (ID 3). This is the parent company.")
return
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]
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) ---")
# Calculate Estimated Sale Date (e.g., 30 days from now)
sale_date = (datetime.utcnow() + timedelta(days=30)).isoformat() + "Z"
# 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,
"Saledate": sale_date, # MANDATORY: Estimated closing date
"Currency": { "Id": eur_id },
"SaleType": { "Id": 14 }, # FIXED: 14 = Roboplanet 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)}")
# Uncomment to actually run creation
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?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()

View File

@@ -0,0 +1,29 @@
import os
import sys
# Füge das aktuelle Verzeichnis zum Python-Pfad hinzu, damit config.py gefunden wird.
sys.path.append(os.getcwd())
try:
from config import settings
except ImportError:
print("Error: Could not import 'settings' from 'config.py'.")
sys.exit(1)
print("--- SuperOffice Configuration Debug ---")
print(f"Environment: {settings.SO_ENVIRONMENT}")
print(f"Client ID: {settings.SO_CLIENT_ID[:5]}... (Length: {len(settings.SO_CLIENT_ID)})")
# Secret nicht ausgeben, nur ob gesetzt
if settings.SO_CLIENT_SECRET:
print(f"Client Secret Set: Yes (Length: {len(settings.SO_CLIENT_SECRET)})")
else:
print("Client Secret Set: No")
if settings.SO_REFRESH_TOKEN:
print(f"Refresh Token Set: Yes (Length: {len(settings.SO_REFRESH_TOKEN)})")
else:
print("Refresh Token Set: No")
print(f"Context Identifier: {settings.SO_CONTEXT_IDENTIFIER}")
print(f"Redirect URI: {settings.SO_REDIRECT_URI}")
print("---------------------------------------")

View File

@@ -0,0 +1,18 @@
import os
import json
import sys
# Setup Paths
connector_dir = "/app/connector-superoffice"
sys.path.append(connector_dir)
from superoffice_client import SuperOfficeClient
def debug_person(person_id):
so_client = SuperOfficeClient()
person = so_client.get_person(person_id)
print("--- FULL PERSON DATA ---")
print(json.dumps(person, indent=2))
if __name__ == "__main__":
debug_person(4)

View File

@@ -0,0 +1,87 @@
import os
import json
import logging
from dotenv import load_dotenv
load_dotenv(override=True)
from superoffice_client import SuperOfficeClient
from config import settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("diagnose-email")
def diagnose():
print("🔍 Starting Email Capability Diagnosis...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# 1. Check Licenses / Capabilities via Associate/Me
print("\n--- 1. User & License Check ---")
try:
me = client._get("Associate/Me")
if me:
print(f"User: {me.get('Name')} (ID: {me.get('AssociateId')})")
print(f"Type: {me.get('Type')}")
# Check for specific functional rights if available in the object
# (Note: API often hides raw license keys, but let's check what we get)
print("Function Rights (TableRight):", me.get("TableRight"))
else:
print("❌ Could not fetch current user.")
except Exception as e:
print(f"❌ User check failed: {e}")
# 2. Check User Preferences (Email Settings)
# Endpoint: GET /Preference/{section}/{key}
# We look for 'Mail' section preferences
print("\n--- 2. Email Preferences (System & User) ---")
pref_keys = [
("Mail", "EmailClient"),
("Mail", "EmailSystem"),
("System", "SoProtocol"),
("Visual", "UseWebTools")
]
for section, key in pref_keys:
try:
# Note: The API for preferences might be /Preference/<Section>/<Key>
# or require a search. Let's try direct access first.
res = client._get(f"Preference/{section}/{key}")
if res:
print(f"✅ Preference '{section}/{key}': {json.dumps(res, indent=2)}")
else:
print(f"❓ Preference '{section}/{key}' not found or empty.")
except Exception as e:
print(f"⚠️ Error checking preference '{section}/{key}': {e}")
# 3. Check for specific functional rights (Archive/List)
# If we can access 'ShipmentType' list, we might have Marketing
print("\n--- 3. Marketing Capability Check ---")
try:
shipment_types = client._get("List/ShipmentType/Items")
if shipment_types:
print(f"✅ Found {len(shipment_types)} Shipment Types (Marketing module likely active).")
for st in shipment_types:
print(f" - {st.get('Name')} (ID: {st.get('Id')})")
else:
print("❌ No Shipment Types found (Marketing module might be inactive/restricted).")
except Exception as e:
print(f"❌ Error checking Shipment Types: {e}")
# 4. Check Document Template for 'Email'
print("\n--- 4. Document Template Configuration ---")
try:
# We know ID 157 exists, let's inspect it closely
tmpl = client._get("DocumentTemplate/157")
if tmpl:
print(f"Template 157: {tmpl.get('Name')}")
print(f" - Generator: {tmpl.get('Generator')}") # Important!
print(f" - Filename: {tmpl.get('Filename')}")
print(f" - Direction: {tmpl.get('Direction')}")
else:
print("❌ Template 157 not found via ID.")
except Exception as e:
print(f"❌ Error checking Template 157: {e}")
if __name__ == "__main__":
diagnose()

View File

@@ -1,89 +1,50 @@
# connector-superoffice/discover_fields.py (Standalone & Robust)
import os
import requests
import json
import os
import sys
from dotenv import load_dotenv
# Load environment variables
load_dotenv(override=True)
from superoffice_client import SuperOfficeClient
# Configuration
SO_ENV = os.getenv("SO_ENVIRONMENT", "sod") # sod, stage, online
SO_CLIENT_ID = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
SO_CLIENT_SECRET = os.getenv("SO_CLIENT_SECRET")
# SO_REDIRECT_URI often required for validation even in refresh flow
SO_REDIRECT_URI = os.getenv("SO_REDIRECT_URI", "http://localhost")
SO_REFRESH_TOKEN = os.getenv("SO_REFRESH_TOKEN")
# Force unbuffered stdout
sys.stdout.reconfigure(line_buffering=True)
def get_access_token():
"""Refreshes the access token using the refresh token."""
url = f"https://{SO_ENV}.superoffice.com/login/common/oauth/tokens"
data = {
"grant_type": "refresh_token",
"client_id": SO_CLIENT_ID,
"client_secret": SO_CLIENT_SECRET,
"refresh_token": SO_REFRESH_TOKEN,
"redirect_uri": SO_REDIRECT_URI
}
def discover():
print("🔍 Starting SuperOffice Discovery Tool (Direct Sending)...")
print(f"DEBUG: Refreshing token at {url} for Client ID {SO_CLIENT_ID[:5]}...")
response = requests.post(url, data=data)
if response.status_code == 200:
print("✅ Access Token refreshed.")
return response.json().get("access_token")
else:
print(f"❌ Error getting token: {response.text}")
return None
def discover_udfs(base_url, token, entity="Contact"):
"""
Fetches the UDF layout for a specific entity.
entity: 'Contact' (Firma) or 'Person'
"""
endpoint = "Contact" if entity == "Contact" else "Person"
url = f"{base_url}/api/v1/{endpoint}?$top=1&$select=userDefinedFields"
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json"
}
print(f"\n--- DISCOVERING UDFS FOR: {entity} ---")
try:
response = requests.get(url, headers=headers)
if response.status_code == 200:
data = response.json()
if data['value']:
item = data['value'][0]
udfs = item.get('userDefinedFields', {})
print(f"Found {len(udfs)} UDFs on this record.")
# Filter logic: Show interesting fields
relevant_udfs = {k: v for k, v in udfs.items() if "marketing" in k.lower() or "robotic" in k.lower() or "challenge" in k.lower() or "ai" in k.lower()}
if relevant_udfs:
print("✅ FOUND RELEVANT FIELDS (ProgId : Value):")
print(json.dumps(relevant_udfs, indent=2))
else:
print("⚠️ No fields matching 'marketing/robotic/ai' found.")
print("First 5 UDFs for context:")
print(json.dumps(list(udfs.keys())[:5], indent=2))
else:
print("No records found to inspect.")
else:
print(f"Error {response.status_code}: {response.text}")
client = SuperOfficeClient()
except Exception as e:
print(f"Request failed: {e}")
print(f"❌ Failed to init client: {e}")
return
if not client.access_token:
print("❌ Auth failed. Check .env")
return
# 4. Check Sending Endpoints
print("\n--- 4. Direct Sending Endpoints ---")
# EMail Agent
print(f"Checking Endpoint: Agents/EMail/GetDefaultEMailFromAddress...")
try:
res = client._get("Agents/EMail/GetDefaultEMailFromAddress")
if res:
print(f"✅ Agents/EMail active. Default From: {json.dumps(res)}")
else:
print(f"❓ Agents/EMail returned None (likely 404/403).")
except Exception as e:
print(f"❌ Agents/EMail check failed: {e}")
# TicketMessage
print(f"Checking Endpoint: Archive/dynamic (Ticket)...")
try:
res = client._get("Archive/dynamic?$select=all&$top=1&entity=ticket")
if res:
print(f"✅ Ticket entities found. Service module active.")
else:
print(f"❓ No Ticket entities found (Service module inactive?).")
except Exception as e:
print(f"❌ Ticket check failed: {e}")
if __name__ == "__main__":
token = get_access_token()
if token:
# Hardcoded Base URL for Cust55774 (Fix: Use app-sod as per README)
base_url = "https://app-sod.superoffice.com/Cust55774"
discover_udfs(base_url, token, "Person")
discover_udfs(base_url, token, "Contact")
else:
print("Could not get Access Token. Check .env")
discover()

View File

@@ -3,11 +3,18 @@ from dotenv import load_dotenv
import urllib.parse
def generate_url():
load_dotenv(dotenv_path="/home/node/clawd/.env")
import os
from dotenv import load_dotenv
import urllib.parse
client_id = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
redirect_uri = "https://devnet-tools.superoffice.com/openid/callback" # Das muss im Portal so registriert sein
state = "12345"
# Try current and parent dir
load_dotenv()
load_dotenv(dotenv_path="../.env")
client_id = os.getenv("SO_CLIENT_ID")
# MUST match what is registered in the SuperOffice Developer Portal for this Client ID
redirect_uri = os.getenv("SO_REDIRECT_URI", "http://localhost")
state = "roboplanet_prod_init"
if not client_id:
print("Fehler: Keine SO_CLIENT_ID in der .env gefunden!")
@@ -17,19 +24,24 @@ def generate_url():
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "openid offline_access", # Wichtig für Refresh Token
"scope": "openid", # Basic scope
"state": state
}
base_url = "https://sod.superoffice.com/login/common/oauth/authorize"
# Use online.superoffice.com for Production
base_url = "https://online.superoffice.com/login/common/oauth/authorize"
auth_url = f"{base_url}?{urllib.parse.urlencode(params)}"
print("\nBitte öffne diese URL im Browser:")
print("\n--- PRODUKTIV-AUTH-LINK ---")
print(f"Mandant: {os.getenv('SO_CONTEXT_IDENTIFIER', 'Cust26720')}")
print(f"Client ID: {client_id[:5]}...")
print("-" * 60)
print(auth_url)
print("-" * 60)
print("\nNach dem Login wirst du auf eine Seite weitergeleitet, die nicht lädt (localhost).")
print("Kopiere die URL aus der Adresszeile und gib mir den Wert nach '?code='.")
print("\n1. Öffne diesen Link im Browser.")
print("2. Logge dich in deinen ECHTEN Mandanten ein (Cust26720).")
print("3. Nach der Bestätigung kopiere die URL aus der Adresszeile.")
print("4. Paste die URL hier in den Chat.")
if __name__ == "__main__":
generate_url()

View File

@@ -0,0 +1,126 @@
import os
import csv
import logging
import requests
import json
from dotenv import load_dotenv
# --- Configuration ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger("customer_product_report")
OUTPUT_FILE = 'product_report.csv'
SALE_LIMIT = 1000 # Process the top 1000 most recently updated sales
PRODUCT_KEYWORDS = [
'OMNIE', 'CD-01', 'RR-02-017', 'Service', 'Dienstleistung',
'Wartung', 'Support', 'Installation', 'Beratung'
]
# --- Auth & API Client Classes (from previous scripts) ---
class AuthHandler:
def __init__(self):
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)
resp.raise_for_status()
return resp.json().get("access_token")
except requests.RequestException 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.base_url = f"https://{self.auth_handler.env}.superoffice.com/{self.auth_handler.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):
url = f"{self.base_url}/{endpoint}"
logger.debug(f"GET: {url}")
resp = requests.get(url, headers=self.headers)
if resp.status_code == 204: return None
resp.raise_for_status()
return resp.json()
def find_keywords(text):
"""Searches for keywords in a given text, case-insensitively."""
found = []
if not text:
return found
text_lower = text.lower()
for keyword in PRODUCT_KEYWORDS:
if keyword.lower() in text_lower:
found.append(keyword)
return found
def main():
logger.info("--- Starting Customer Product Report Generation ---")
try:
auth = AuthHandler()
client = SuperOfficeClient(auth)
# 1. Fetch the most recently updated sales
logger.info(f"Fetching the last {SALE_LIMIT} updated sales...")
# OData query to get the top N sales that have a contact associated
sales_endpoint = f"Sale?$filter=Contact ne null&$orderby=saleId desc&$top={SALE_LIMIT}&$select=SaleId,Heading,Contact"
sales_response = client._get(sales_endpoint)
if not sales_response or 'value' not in sales_response:
logger.warning("No sales with associated contacts found.")
return
sales = sales_response['value']
logger.info(f"Found {len(sales)} sales with associated contacts to process.")
# Removed the debug log to avoid excessive output of the same data
# 2. Process each sale and write to CSV
with open(OUTPUT_FILE, 'w', newline='', encoding='utf-8') as csvfile:
fieldnames = ['SaleID', 'CustomerName', 'SaleHeading', 'DetectedKeywords']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for sale in sales:
if not sale.get('Contact') or not sale['Contact'].get('ContactId'):
logger.warning(f"Skipping Sale {sale.get('SaleId')} because it has no linked Contact.")
continue
sale_id = sale.get('SaleId')
heading = sale.get('Heading', 'N/A')
customer_name = sale['Contact'].get('Name', 'N/A')
# Find keywords in the heading
keywords_found = find_keywords(heading)
writer.writerow({
'SaleID': sale_id,
'CustomerName': customer_name,
'SaleHeading': heading,
'DetectedKeywords': ', '.join(keywords_found) if keywords_found else 'None'
})
logger.info(f"--- ✅ Report generation complete. ---")
logger.info(f"Results saved to '{OUTPUT_FILE}'.")
except requests.exceptions.HTTPError as e:
logger.error(f"❌ API Error: {e.response.status_code} - {e.response.text}")
except Exception as e:
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,70 @@
import datetime
from datetime import date
import holidays
# Configuration
YEARS_TO_GENERATE = [2025, 2026]
COUNTRY_CODE = "DE"
SUB_REGION = "BY" # Bayern (Wackler HQ)
def generate_crm_script():
print(f"Generating CRMScript for Holidays ({COUNTRY_CODE}-{SUB_REGION})...")
# 1. Calculate Holidays
holidays_list = []
de_holidays = holidays.Country(COUNTRY_CODE, subdiv=SUB_REGION)
for year in YEARS_TO_GENERATE:
for date_obj, name in de_holidays.items():
if date_obj.year == year:
holidays_list.append((date_obj, name))
# Sort by date
holidays_list.sort(key=lambda x: x[0])
# 2. Generate CRMScript Code
script = f"// --- AUTO-GENERATED HOLIDAY IMPORT SCRIPT ---
"
script += f"// Generated for: {COUNTRY_CODE}-{SUB_REGION} (Years: {YEARS_TO_GENERATE})\n"
script += f"// Target Table: y_holidays (Must exist! Columns: x_date, x_name)\n\n"
script += "Integer count = 0;
"
script += "DateTime date;
"
script += "String name;
\n"
for d, name in holidays_list:
# Format date for CRMScript (usually specific format required, depends on locale but DateTime can parse ISO often)
# Better: use explicit construction or string
date_str = d.strftime("%Y-%m-%d")
script += f"date = String(\"{date_str}\").toDateTime();\n"
script += f"name = \"{name}\";\n"
# Check if exists to avoid dupes (pseudo-code, adapting to likely CRMScript API)
# Usually we use specific SearchEngine or similar.
# Simple version: Just insert. Admin should clear table before run if needed.
script += f"// Inserting {date_str} - {name}\n"
script += "GenericEntity holiday = getDatabaseConnection().createGenericEntity(\"y_holidays\");\n"
script += "holiday.setValue(\"x_date\", date);
"
script += "holiday.setValue(\"x_name\", name);
"
script += "holiday.save();\n"
script += "count++;\n\n"
script += "print(\"Imported \" + count.toString() + \" holidays.\");\n"
# 3. Output
output_filename = "import_holidays_CRMSCRIPT.txt"
with open(output_filename, "w", encoding="utf-8") as f:
f.write(script)
print(f"✅ CRMScript generated: {output_filename}")
print("👉 Copy the content of this file and run it in SuperOffice (Settings -> CRMScript -> Execute).")
if __name__ == "__main__":
generate_crm_script()

View File

@@ -28,7 +28,11 @@ class AuthHandler:
return self._refresh_access_token()
def _refresh_access_token(self):
url = f"https://{self.env}.superoffice.com/login/common/oauth/tokens"
# OAuth token endpoint is ALWAYS online.superoffice.com for production,
# or sod.superoffice.com for sandbox.
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,
@@ -38,12 +42,12 @@ class AuthHandler:
}
try:
resp = requests.post(url, data=data)
resp.raise_for_status()
if resp.status_code != 200:
logger.error(f"❌ Token Refresh Failed (Status {resp.status_code}): {resp.text}")
return None
logger.info("Access token refreshed successfully.")
return resp.json().get("access_token")
except requests.exceptions.HTTPError as e:
logger.error(f"❌ Token Refresh Error (Status: {e.response.status_code}): {e.response.text}")
return None
except Exception as e:
logger.error(f"❌ Connection Error during token refresh: {e}")
return None
@@ -53,7 +57,8 @@ class SuperOfficeClient:
self.auth_handler = auth_handler
self.env = os.getenv("SO_ENVIRONMENT", "sod")
self.cust_id = os.getenv("SO_CONTEXT_IDENTIFIER", "Cust55774")
self.base_url = f"https://app-{self.env}.superoffice.com/{self.cust_id}/api/v1"
# API base URL: online3.superoffice.com is valid here
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 during SuperOfficeClient initialization.")

View File

@@ -0,0 +1,20 @@
import os
import json
import logging
from dotenv import load_dotenv
load_dotenv(override=True)
from superoffice_client import SuperOfficeClient
logging.basicConfig(level=logging.INFO)
def inspect_person(person_id: int):
client = SuperOfficeClient()
print(f"📡 Fetching FULL Person {person_id}...")
person = client._get(f"Person/{person_id}")
if person:
print(json.dumps(person, indent=2))
else:
print("❌ Person not found.")
if __name__ == "__main__":
inspect_person(193036)

View File

@@ -0,0 +1,122 @@
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("list_products")
# --- 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):
url = f"{self.base_url}/{endpoint}"
logger.info(f"Attempting to GET: {url}")
resp = requests.get(url, headers=self.headers)
resp.raise_for_status()
return resp.json()
def main():
try:
# Initialize Auth and Client
auth = AuthHandler()
client = SuperOfficeClient(auth)
print("\n--- 1. Fetching Product Information ---")
product_families = []
endpoint_to_try = "List/ProductFamily/Items"
try:
product_families = client._get(endpoint_to_try)
except requests.exceptions.HTTPError as e:
logger.error(f"Failed to fetch from '{endpoint_to_try}': {e}")
print(f"Could not find Product Families at '{endpoint_to_try}'. This might not be the correct endpoint or list name.")
# If the first endpoint fails, you could try others here.
# For now, we will exit if this one fails.
return
if not product_families:
print("No product families found or the endpoint returned an empty list.")
return
print("\n--- ✅ SUCCESS: Found Product Families ---")
print("-----------------------------------------")
print(f"{'ID':<10} | {'Name':<30} | {'Tooltip':<40}")
print("-----------------------------------------")
for product in product_families:
product_id = product.get('Id', 'N/A')
name = product.get('Name', 'N/A')
tooltip = product.get('Tooltip', 'N/A')
print(f"{str(product_id):<10} | {name:<30} | {tooltip:<40}")
print("-----------------------------------------")
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()

View File

@@ -2,51 +2,89 @@ import sqlite3
import json
from datetime import datetime, timedelta
import os
import logging
DB_PATH = os.getenv("DB_PATH", "connector_queue.db")
logger = logging.getLogger("connector-queue")
# HARDCODED PATH TO FORCE CONSISTENCY
DB_PATH = "/data/connector_queue.db"
class JobQueue:
def __init__(self):
self._init_db()
def _init_db(self):
with sqlite3.connect(DB_PATH) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT,
payload TEXT,
status TEXT DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
error_msg TEXT,
next_try_at TIMESTAMP
)
""")
# Migration for existing DBs
with sqlite3.connect(DB_PATH, timeout=30) as conn:
try:
conn.execute("ALTER TABLE jobs ADD COLUMN next_try_at TIMESTAMP")
except sqlite3.OperationalError:
pass
# Revert to default journal mode for problematic Synology mounts
conn.execute("PRAGMA journal_mode=DELETE")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA mmap_size=0")
conn.execute("""
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT,
payload TEXT,
entity_name TEXT,
status TEXT DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
error_msg TEXT,
next_try_at TIMESTAMP
)
""")
# Migration for existing DBs
try: conn.execute("ALTER TABLE jobs ADD COLUMN next_try_at TIMESTAMP")
except sqlite3.OperationalError: pass
try: conn.execute("ALTER TABLE jobs ADD COLUMN entity_name TEXT")
except sqlite3.OperationalError: pass
try: conn.execute("ALTER TABLE jobs ADD COLUMN associate_name TEXT")
except sqlite3.OperationalError: pass
conn.commit()
logger.info("Database initialized with PRAGMA settings (DELETE, NORMAL, mmap=0).")
except Exception as e:
logger.critical(f"❌ CRITICAL DB INIT ERROR: {e}", exc_info=True)
raise
def add_job(self, event_type: str, payload: dict):
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"INSERT INTO jobs (event_type, payload, status) VALUES (?, ?, ?)",
(event_type, json.dumps(payload), 'PENDING')
)
with sqlite3.connect(DB_PATH, timeout=30) as conn:
try:
conn.execute(
"INSERT INTO jobs (event_type, payload, status) VALUES (?, ?, ?)",
(event_type, json.dumps(payload), 'PENDING')
)
conn.commit()
logger.debug(f"Job added: {event_type}")
except Exception as e:
logger.error(f"❌ Failed to add job: {e}", exc_info=True)
conn.rollback()
raise
def update_entity_name(self, job_id, name, associate_name=None):
with sqlite3.connect(DB_PATH, timeout=30) as conn:
try:
if associate_name:
conn.execute(
"UPDATE jobs SET entity_name = ?, associate_name = ?, updated_at = datetime('now') WHERE id = ?",
(str(name), str(associate_name), job_id)
)
else:
conn.execute(
"UPDATE jobs SET entity_name = ?, updated_at = datetime('now') WHERE id = ?",
(str(name), job_id)
)
conn.commit()
logger.debug(f"Entity name updated for job {job_id}")
except Exception as e:
logger.error(f"❌ Failed to update entity name for job {job_id}: {e}", exc_info=True)
conn.rollback()
raise
def get_next_job(self):
"""
Atomically fetches the next pending job where next_try_at is reached.
"""
job = None
with sqlite3.connect(DB_PATH) as conn:
with sqlite3.connect(DB_PATH, timeout=30) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Lock the job
cursor.execute("BEGIN EXCLUSIVE")
try:
cursor.execute("""
SELECT id, event_type, payload, created_at
@@ -60,47 +98,242 @@ class JobQueue:
if row:
job = dict(row)
# Mark as processing
cursor.execute(
"UPDATE jobs SET status = 'PROCESSING', updated_at = datetime('now') WHERE id = ?",
(job['id'],)
)
conn.commit()
else:
conn.rollback() # No job found
except Exception:
conn.rollback()
raise
logger.info(f"Fetched and marked job {job['id']} as PROCESSING.")
except Exception as e:
logger.error(f"❌ Failed to get next job or mark as PROCESSING: {e}", exc_info=True)
conn.rollback()
if job:
job['payload'] = json.loads(job['payload'])
try:
job['payload'] = json.loads(job['payload'])
except Exception as e:
logger.error(f"❌ Failed to parse payload for job {job['id']}: {e}")
return None
return job
def retry_job_later(self, job_id, delay_seconds=60):
def retry_job_later(self, job_id, delay_seconds=60, error_msg=None):
next_try = datetime.utcnow() + timedelta(seconds=delay_seconds)
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"UPDATE jobs SET status = 'PENDING', next_try_at = ?, updated_at = datetime('now') WHERE id = ?",
(next_try, job_id)
)
with sqlite3.connect(DB_PATH, timeout=30) as conn:
try:
if error_msg:
conn.execute(
"UPDATE jobs SET status = 'PENDING', next_try_at = ?, updated_at = datetime('now'), error_msg = ? WHERE id = ?",
(next_try, str(error_msg), job_id)
)
else:
conn.execute(
"UPDATE jobs SET status = 'PENDING', next_try_at = ?, updated_at = datetime('now') WHERE id = ?",
(next_try, job_id)
)
conn.commit()
logger.warning(f"Job {job_id} set to RETRY. Next attempt at {next_try}.")
except Exception as e:
logger.error(f"❌ Failed to set job {job_id} to RETRY: {e}", exc_info=True)
conn.rollback()
def complete_job(self, job_id):
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"UPDATE jobs SET status = 'COMPLETED', updated_at = datetime('now') WHERE id = ?",
(job_id,)
)
with sqlite3.connect(DB_PATH, timeout=30) as conn:
try:
conn.execute(
"UPDATE jobs SET status = 'COMPLETED', updated_at = datetime('now') WHERE id = ?",
(job_id,)
)
conn.commit()
logger.info(f"Job {job_id} COMPLETED.")
except Exception as e:
logger.error(f"❌ Failed to set job {job_id} to COMPLETED: {e}", exc_info=True)
conn.rollback()
def skip_job(self, job_id, reason):
with sqlite3.connect(DB_PATH, timeout=30) as conn:
try:
conn.execute(
"UPDATE jobs SET status = 'SKIPPED', error_msg = ?, updated_at = datetime('now') WHERE id = ?",
(str(reason), job_id)
)
conn.commit()
logger.info(f"Job {job_id} SKIPPED: {reason}")
except Exception as e:
logger.error(f"❌ Failed to set job {job_id} to SKIPPED: {e}", exc_info=True)
conn.rollback()
def mark_as_deleted(self, job_id, reason):
with sqlite3.connect(DB_PATH, timeout=30) as conn:
try:
conn.execute(
"UPDATE jobs SET status = 'DELETED', error_msg = ?, updated_at = datetime('now') WHERE id = ?",
(str(reason), job_id)
)
conn.commit()
logger.info(f"Job {job_id} DELETED: {reason}")
except Exception as e:
logger.error(f"❌ Failed to set job {job_id} to DELETED: {e}", exc_info=True)
conn.rollback()
def fail_job(self, job_id, error_msg):
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"UPDATE jobs SET status = 'FAILED', error_msg = ?, updated_at = datetime('now') WHERE id = ?",
(str(error_msg), job_id)
)
with sqlite3.connect(DB_PATH, timeout=30) as conn:
try:
conn.execute(
"UPDATE jobs SET status = 'FAILED', error_msg = ?, updated_at = datetime('now') WHERE id = ?",
(str(error_msg), job_id)
)
conn.commit()
logger.error(f"Job {job_id} FAILED: {error_msg}")
except Exception as e:
logger.critical(f"❌ CRITICAL: Failed to set job {job_id} to FAILED: {e}", exc_info=True)
conn.rollback()
def get_stats(self):
with sqlite3.connect(DB_PATH) as conn:
with sqlite3.connect(DB_PATH, timeout=30) as conn:
cursor = conn.cursor()
cursor.execute("SELECT status, COUNT(*) FROM jobs GROUP BY status")
return dict(cursor.fetchall())
def get_recent_jobs(self, limit=50):
with sqlite3.connect(DB_PATH, timeout=30) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT id, event_type, status, created_at, updated_at, error_msg, payload, entity_name, associate_name
FROM jobs
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
results = []
for row in rows:
r = dict(row)
try:
r['payload'] = json.loads(r['payload'])
except:
pass
results.append(r)
return results
def get_account_summary(self, limit=1000):
"""
Groups recent jobs into logical 'Sync-Runs' using time-gap clustering.
If a job for the same ID is more than 15 mins apart, it's a new run.
"""
jobs = self.get_recent_jobs(limit=limit)
runs = []
# Temporary storage to track the latest run for each ID
# Format: { 'C123': [run_obj1, run_obj2, ...] }
id_to_runs = {}
# Jobs are sorted by updated_at DESC (newest first)
for job in jobs:
payload = job.get('payload', {})
c_id = payload.get('ContactId')
p_id = payload.get('PersonId')
if not c_id and payload.get('PrimaryKey') and 'contact' in job['event_type'].lower():
c_id = payload.get('PrimaryKey')
if not p_id and payload.get('PrimaryKey') and 'person' in job['event_type'].lower():
p_id = payload.get('PrimaryKey')
if not c_id and not p_id:
continue
entity_id = f"P{p_id}" if p_id else f"C{c_id}"
job_time = datetime.strptime(job['updated_at'], "%Y-%m-%d %H:%M:%S")
target_run = None
# Check if we can attach this job to an existing (newer) run cluster
if entity_id in id_to_runs:
for run in id_to_runs[entity_id]:
run_latest_time = datetime.strptime(run['updated_at'], "%Y-%m-%d %H:%M:%S")
# If this job is within 15 mins of the run's activity
if abs((run_latest_time - job_time).total_seconds()) < 900:
target_run = run
break
if not target_run:
# Start a new run cluster
target_run = {
"id": f"{entity_id}_{job['id']}", # Unique ID for this run row
"entity_id": entity_id,
"contact_id": c_id,
"person_id": p_id,
"name": job.get('entity_name') or "Unknown",
"associate": job.get('associate_name') or "",
"last_event": job['event_type'],
"status": job['status'],
"created_at": job['created_at'],
"updated_at": job['updated_at'],
"error_msg": job['error_msg'],
"job_count": 0,
"duration": "0s",
"phases": {
"received": "pending",
"enriching": "pending",
"syncing": "pending",
"completed": "pending"
}
}
runs.append(target_run)
if entity_id not in id_to_runs:
id_to_runs[entity_id] = []
id_to_runs[entity_id].append(target_run)
# Update the run with job info
target_run["job_count"] += 1
# Update oldest start time (since we iterate newest -> oldest)
target_run["created_at"] = job["created_at"]
# Calculate Duration for this run
try:
start = datetime.strptime(target_run["created_at"], "%Y-%m-%d %H:%M:%S")
end = datetime.strptime(target_run["updated_at"], "%Y-%m-%d %H:%M:%S")
diff = end - start
seconds = int(diff.total_seconds())
target_run["duration"] = f"{seconds}s" if seconds < 60 else f"{seconds // 60}m {seconds % 60}s"
except: pass
# Resolve Name & Associate (if not already set from a newer job in this cluster)
if target_run["name"] == "Unknown":
name = job.get('entity_name') or payload.get('Name') or payload.get('crm_name') or payload.get('FullName') or payload.get('ContactName')
if not name and payload.get('Firstname'):
name = f"{payload.get('Firstname')} {payload.get('Lastname', '')}".strip()
if name: target_run["name"] = name
if not target_run["associate"] and job.get('associate_name'):
target_run["associate"] = job['associate_name']
# Update Status based on the jobs in the run
# Update Status based on the jobs in the run
# Priority: FAILED > PROCESSING > COMPLETED > SKIPPED > PENDING
status_priority = {"FAILED": 4, "PROCESSING": 3, "COMPLETED": 2, "SKIPPED": 1, "PENDING": 0}
current_prio = status_priority.get(target_run["status"], -1)
new_prio = status_priority.get(job["status"], -1)
# CRITICAL: We only update the status if the new job has a HIGHER priority
# Example: If current is COMPLETED (2) and new is SKIPPED (1), we keep COMPLETED.
if new_prio > current_prio:
target_run["status"] = job["status"]
target_run["error_msg"] = job["error_msg"]
# Set visual phases based on status
if job["status"] == "COMPLETED":
target_run["phases"] = {"received": "completed", "enriching": "completed", "syncing": "completed", "completed": "completed"}
elif job["status"] == "FAILED":
target_run["phases"] = {"received": "completed", "enriching": "failed", "syncing": "pending", "completed": "pending"}
elif job["status"] == "PROCESSING":
target_run["phases"] = {"received": "completed", "enriching": "processing", "syncing": "pending", "completed": "pending"}
# Note: SKIPPED (1) and PENDING (0) will use the target_run's initial phases or keep previous ones.
# SPECIAL CASE: If we already have COMPLETED but a new job is SKIPPED, we might want to keep the error_msg empty
# to avoid showing "Skipped Echo" on a successful row.
if target_run["status"] == "COMPLETED" and job["status"] == "SKIPPED":
pass # Keep everything from the successful run
# Final cleanup
for r in runs:
if r["name"] == "Unknown": r["name"] = f"Entity {r['entity_id']}"
return runs

View File

@@ -1,10 +1,17 @@
import sys
import os
import os
import sys
from dotenv import load_dotenv
# Load .env BEFORE importing SuperOfficeClient to ensure settings are correctly initialized
load_dotenv(os.path.join(os.path.dirname(__file__), "../.env"), override=True)
from superoffice_client import SuperOfficeClient
# Configuration
WEBHOOK_NAME = "Gemini Connector Hook"
TARGET_URL = "https://floke-ai.duckdns.org/connector/webhook?token=changeme" # Token match .env
WEBHOOK_NAME = "Gemini Connector Production"
TARGET_URL = f"https://floke-ai.duckdns.org/connector/webhook?token={os.getenv('WEBHOOK_TOKEN')}"
EVENTS = [
"contact.created",
"contact.changed",
@@ -13,13 +20,17 @@ EVENTS = [
]
def register():
print("🚀 Initializing SuperOffice Client...")
print(f"🚀 Initializing SuperOffice Client for Production...")
try:
client = SuperOfficeClient()
except Exception as e:
print(f"❌ Failed to connect: {e}")
return
if not client.access_token:
print("❌ Auth failed. Check SO_CLIENT_ID and SO_REFRESH_TOKEN in .env")
return
print("🔎 Checking existing webhooks...")
webhooks = client._get("Webhook")
@@ -30,17 +41,33 @@ def register():
# Check if URL matches
if wh['TargetUrl'] != TARGET_URL:
print(f" ⚠️ URL Mismatch! Deleting old webhook...")
# Warning: _delete is not implemented in generic client yet, skipping auto-fix
print(f" ⚠️ URL Mismatch!")
print(f" Existing: {wh['TargetUrl']}")
print(f" New: {TARGET_URL}")
print(" Please delete it manually via API or extend client.")
else:
print(f" ✅ Webhook is up to date.")
if wh['State'] != 'Active':
print(f" ⚠️ Webhook is '{wh['State']}'. Reactivating...")
res = client._put(f"Webhook/{wh['WebhookId']}", {"State": "Active"})
if res:
print(f" ✅ Webhook reactivated.")
else:
print(f" ❌ Failed to reactivate webhook.")
return
print(f"✨ Registering new webhook: {WEBHOOK_NAME}")
webhook_secret = os.getenv('WEBHOOK_SECRET')
if not webhook_secret:
print("❌ Error: WEBHOOK_SECRET missing in .env")
return
payload = {
"Name": WEBHOOK_NAME,
"Events": EVENTS,
"TargetUrl": TARGET_URL,
"Secret": "changeme", # Used for signature calculation by SO
"Secret": webhook_secret, # Used for signature calculation by SO
"State": "Active",
"Type": "Webhook"
}

View File

@@ -0,0 +1,44 @@
import os
import requests
import json
import logging
from superoffice_client import SuperOfficeClient
from config import settings
# Setup Logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("simulation-e2e")
def simulate_sendout(contact_id: int):
print(f"🚀 Starting Appointment Creation Test for Contact {contact_id}...")
# 1. Initialize SuperOffice Client
so_client = SuperOfficeClient()
if not so_client.access_token:
print("❌ Auth failed. Check .env")
return
# 2. Create Appointment (The "Sendout Proof")
print("📅 Creating Appointment as sendout proof...")
app_subject = f"[DIAGNOSTIC TEST] Can we create an activity?"
app_desc = f"This is a test to see if the API user can create appointments."
appointment = so_client.create_appointment(
subject=app_subject,
description=app_desc,
contact_id=contact_id,
person_id=None # Explicitly test without a person
)
if appointment:
# The key might be 'appointmentId' (lowercase 'a')
appt_id = appointment.get('appointmentId') or appointment.get('AppointmentId')
print(f"✅ SUCCESS! Appointment Created with ID: {appt_id}")
print(f"🔗 Check SuperOffice for Contact {contact_id} and look at the activities.")
else:
print("❌ Failed to create appointment.")
if __name__ == "__main__":
# Using the IDs we know exist from previous tests/status
TEST_CONTACT_ID = 171185
simulate_sendout(TEST_CONTACT_ID)

View File

@@ -1,6 +0,0 @@
#!/bin/bash
# Start Worker in background
python worker.py &
# Start Webhook Server in foreground
uvicorn webhook_app:app --host 0.0.0.0 --port 8000

View File

@@ -1,129 +1,172 @@
import os
import requests
import json
from dotenv import load_dotenv
from config import settings
import logging
load_dotenv(override=True)
# Configure Logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("superoffice-client")
class ContactNotFoundException(Exception):
"""Custom exception for 404 errors on Contact/Person lookups."""
pass
class SuperOfficeClient:
"""A client for interacting with the SuperOffice REST API."""
def __init__(self):
# Helper to strip quotes if Docker passed them literally
def get_clean_env(key, default=None):
val = os.getenv(key)
if val and val.strip(): # Check if not empty string
return val.strip('"').strip("'")
return default
# Configuration
self.client_id = settings.SO_CLIENT_ID
self.client_secret = settings.SO_CLIENT_SECRET
self.refresh_token = settings.SO_REFRESH_TOKEN
self.env = settings.SO_ENVIRONMENT
self.cust_id = settings.SO_CONTEXT_IDENTIFIER
self.client_id = get_clean_env("SO_CLIENT_ID") or get_clean_env("SO_SOD")
self.client_secret = get_clean_env("SO_CLIENT_SECRET")
self.refresh_token = get_clean_env("SO_REFRESH_TOKEN")
self.redirect_uri = get_clean_env("SO_REDIRECT_URI", "http://localhost")
self.env = get_clean_env("SO_ENVIRONMENT", "sod")
self.cust_id = get_clean_env("SO_CONTEXT_IDENTIFIER", "Cust55774") # Fallback for your dev
logger.info(f"DEBUG CONFIG: Env='{self.env}', CustID='{self.cust_id}', ClientID='{self.client_id[:4]}...'")
if not all([self.client_id, self.client_secret, self.refresh_token]):
raise ValueError("SuperOffice credentials missing in .env file.")
# Graceful failure: Log error but allow init (for help/docs/discovery scripts)
logger.error("❌ SuperOffice credentials missing in .env file (or environment variables).")
self.base_url = None
self.access_token = None
return
self.base_url = f"https://app-{self.env}.superoffice.com/{self.cust_id}/api/v1"
self.base_url = f"https://{self.env}.superoffice.com/{self.cust_id}/api/v1"
self.access_token = self._refresh_access_token()
if not self.access_token:
raise Exception("Failed to authenticate with SuperOffice.")
self.headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"Accept": "application/json"
}
print("✅ SuperOffice Client initialized and authenticated.")
if not self.access_token:
logger.error("❌ Failed to authenticate with SuperOffice.")
else:
self.headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"Accept": "application/json"
}
logger.info("✅ SuperOffice Client initialized and authenticated.")
def _refresh_access_token(self):
"""Refreshes and returns a new access token."""
url = f"https://{self.env}.superoffice.com/login/common/oauth/tokens"
print(f"DEBUG: Refresh URL: '{url}' (Env: '{self.env}')") # DEBUG
# OAuth token endpoint is ALWAYS online.superoffice.com for production,
# or sod.superoffice.com for sandbox.
token_domain = "online.superoffice.com" if "online" in self.env.lower() else "sod.superoffice.com"
url = f"https://{token_domain}/login/common/oauth/tokens"
logger.debug(f"DEBUG: Refresh URL: '{url}' (Env: '{self.env}')")
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
"redirect_uri": settings.SO_REDIRECT_URI
}
try:
resp = requests.post(url, data=data)
# Catch non-JSON responses early
if resp.status_code != 200:
logger.error(f"❌ Token Refresh Failed (Status {resp.status_code})")
logger.error(f"Response Body: {resp.text[:500]}")
return None
resp.raise_for_status()
return resp.json().get("access_token")
except requests.exceptions.HTTPError as e:
print(f"❌ Token Refresh Error: {e.response.text}")
except requests.exceptions.JSONDecodeError:
logger.error(f"❌ Token Refresh Error: Received non-JSON response from {url}")
logger.debug(f"Raw Response: {resp.text[:500]}")
return None
except Exception as e:
print(f"❌ Connection Error during token refresh: {e}")
logger.error(f"❌ Connection Error during token refresh: {e}")
return None
def _request_with_retry(self, method, endpoint, payload=None, retry=True):
"""Helper to handle 401 Unauthorized with auto-refresh."""
if not self.access_token:
if not self._refresh_access_token():
return None
url = f"{self.base_url}/{endpoint}"
try:
if method == "GET":
resp = requests.get(url, headers=self.headers)
elif method == "POST":
resp = requests.post(url, headers=self.headers, json=payload)
elif method == "PUT":
resp = requests.put(url, headers=self.headers, json=payload)
elif method == "PATCH":
resp = requests.patch(url, headers=self.headers, json=payload)
elif method == "DELETE":
resp = requests.delete(url, headers=self.headers)
# 401 Handling
if resp.status_code == 401 and retry:
logger.warning(f"⚠️ 401 Unauthorized for {endpoint}. Attempting Token Refresh...")
new_token = self._refresh_access_token()
if new_token:
logger.info("✅ Token refreshed successfully during retry.")
self.access_token = new_token
self.headers["Authorization"] = f"Bearer {self.access_token}"
# Recursive retry with the new token
return self._request_with_retry(method, endpoint, payload, retry=False)
else:
logger.error("❌ Token Refresh failed during retry.")
return None
if resp.status_code == 204:
return True
resp.raise_for_status()
return resp.json()
except requests.exceptions.HTTPError as e:
# Explicitly handle 404 Not Found for GET requests
if method == "GET" and e.response.status_code == 404:
logger.warning(f"🔍 404 Not Found for GET request to {endpoint}.")
raise ContactNotFoundException(f"Entity not found at {endpoint}") from e
logger.error(f"❌ API {method} Error for {endpoint} (Status: {e.response.status_code}): {e.response.text}")
return None
except Exception as e:
logger.error(f"❌ Connection Error during {method} for {endpoint}: {e}")
return None
def _get(self, endpoint):
"""Generic GET request."""
try:
resp = requests.get(f"{self.base_url}/{endpoint}", headers=self.headers)
resp.raise_for_status()
return resp.json()
except requests.exceptions.HTTPError as e:
print(f"❌ API GET Error for {endpoint}: {e.response.text}")
return None
return self._request_with_retry("GET", endpoint)
def _put(self, endpoint, payload):
"""Generic PUT request."""
try:
resp = requests.put(f"{self.base_url}/{endpoint}", headers=self.headers, json=payload)
resp.raise_for_status()
return resp.json()
except requests.exceptions.HTTPError as e:
print(f"❌ API PUT Error for {endpoint}: {e.response.text}")
return None
return self._request_with_retry("PUT", endpoint, payload)
def get_person(self, person_id):
"""Gets a single person by ID."""
return self._get(f"Person/{person_id}")
def _patch(self, endpoint, payload):
return self._request_with_retry("PATCH", endpoint, payload)
def get_contact(self, contact_id):
"""Gets a single contact (company) by ID."""
return self._get(f"Contact/{contact_id}")
def _post(self, endpoint, payload):
return self._request_with_retry("POST", endpoint, payload)
def update_udfs(self, entity: str, entity_id: int, udf_payload: dict):
"""
Updates the UserDefinedFields for a given entity (Person or Contact).
Args:
entity (str): "Person" or "Contact".
entity_id (int): The ID of the entity.
udf_payload (dict): A dictionary of ProgId:Value pairs.
"""
endpoint = f"{entity}/{entity_id}"
# 1. GET the full entity object
existing_data = self._get(endpoint)
if not existing_data:
return False # Error is printed in _get
def _delete(self, endpoint):
return self._request_with_retry("DELETE", endpoint)
# 2. Merge the UDF payload
if "UserDefinedFields" not in existing_data:
existing_data["UserDefinedFields"] = {}
existing_data["UserDefinedFields"].update(udf_payload)
# --- Convenience Wrappers ---
# 3. PUT the full object back
print(f"Updating {entity} {entity_id} with new UDFs...")
result = self._put(endpoint, existing_data)
if result:
print(f"✅ Successfully updated {entity} {entity_id}")
return True
return False
def get_person(self, person_id, select: list = None):
endpoint = f"Person/{person_id}"
if select:
endpoint += f"?$select={','.join(select)}"
return self._get(endpoint)
def get_contact(self, contact_id, select: list = None):
endpoint = f"Contact/{contact_id}"
if select:
endpoint += f"?$select={','.join(select)}"
return self._get(endpoint)
def search(self, query_string: str):
"""
Performs a search using OData syntax and handles pagination.
Example: "Person?$select=personId&$filter=lastname eq 'Godelmann'"
"""
if not self.access_token: return None
all_results = []
next_page_url = f"{self.base_url}/{query_string}"
@@ -133,91 +176,16 @@ class SuperOfficeClient:
resp.raise_for_status()
data = resp.json()
# Add the items from the current page
all_results.extend(data.get('value', []))
# Check for the next page link
next_page_url = data.get('next_page_url', None)
# Robust Pagination: Check both OData standard and legacy property
next_page_url = data.get('odata.nextLink') or data.get('next_page_url')
except requests.exceptions.HTTPError as e:
print(f"❌ API Search Error for {query_string}: {e.response.text}")
logger.error(f"❌ API Search Error for {query_string}: {e.response.text}")
return None
return all_results
def find_contact_by_criteria(self, name=None, org_nr=None, url=None):
"""
Finds a contact (company) by name, OrgNr, or URL.
Returns the first matching contact or None.
"""
filter_parts = []
if name:
filter_parts.append(f"Name eq '{name}'")
if org_nr:
filter_parts.append(f"OrgNr eq '{org_nr}'")
if url:
filter_parts.append(f"UrlAddress eq '{url}'")
if not filter_parts:
print("❌ No criteria provided for contact search.")
return None
query_string = "Contact?$filter=" + " or ".join(filter_parts)
results = self.search(query_string)
if results:
return results[0] # Return the first match
return None
def _post(self, endpoint, payload):
"""Generic POST request."""
try:
resp = requests.post(f"{self.base_url}/{endpoint}", headers=self.headers, json=payload)
resp.raise_for_status()
return resp.json()
except requests.exceptions.HTTPError as e:
print(f"❌ API POST Error for {endpoint} (Status: {e.response.status_code}): {e.response.text}")
return None
except Exception as e:
print(f"❌ Connection Error during POST for {endpoint}: {e}")
return None
def create_contact(self, name: str, url: str = None, org_nr: str = None):
"""Creates a new contact (company)."""
payload = {"Name": name}
if url:
payload["UrlAddress"] = url
if org_nr:
payload["OrgNr"] = org_nr
print(f"Creating new contact: {name} with payload: {payload}...") # Added payload to log
return self._post("Contact", payload)
def create_person(self, first_name: str, last_name: str, contact_id: int, email: str = None):
"""Creates a new person linked to a contact."""
payload = {
"Firstname": first_name,
"Lastname": last_name,
"Contact": {"ContactId": contact_id}
}
if email:
payload["EmailAddress"] = email
print(f"Creating new person: {first_name} {last_name} for Contact ID {contact_id}...")
return self._post("Person", payload)
def create_sale(self, title: str, contact_id: int, person_id: int, amount: float = None):
"""Creates a new sale (opportunity) linked to a contact and person."""
payload = {
"Heading": title,
"Contact": {"ContactId": contact_id},
"Person": {"PersonId": person_id}
}
if amount:
payload["Amount"] = amount
print(f"Creating new sale: {title}...")
return self._post("Sale", payload)
def create_project(self, name: str, contact_id: int, person_id: int = None):
"""Creates a new project linked to a contact, and optionally adds a person."""
payload = {
@@ -235,29 +203,75 @@ class SuperOfficeClient:
print(f"Creating new project: {name}...")
return self._post("Project", payload)
def create_appointment(self, subject: str, description: str, contact_id: int, person_id: int = None):
"""Creates a new appointment (to simulate a sent activity)."""
import datetime
now = datetime.datetime.utcnow().isoformat() + "Z"
# SuperOffice UI limit: 42 chars.
# We put exactly this in the FIRST line of the description.
short_title = (subject[:39] + '...') if len(subject) > 42 else subject
# SuperOffice often 'steals' the first line of the description for the list view header.
# So we give it exactly the subject it wants, then two newlines for the real body.
formatted_description = f"{short_title}\n\n{description}"
payload = {
"Description": formatted_description,
"Contact": {"ContactId": contact_id},
"StartDate": now,
"EndDate": now,
"MainHeader": short_title,
"Task": {"Id": 1}
}
if person_id:
payload["Person"] = {"PersonId": person_id}
logger.info(f"Creating new appointment: {short_title}...")
return self._post("Appointment", payload)
def update_entity_udfs(self, entity_id: int, entity_type: str, udf_data: dict):
"""
Updates UDFs for a given entity (Contact or Person).
Args:
entity_id (int): ID of the entity.
entity_type (str): 'Contact' or 'Person'.
udf_data (dict): Dictionary with ProgId:Value pairs for UDFs.
Returns:
dict: The updated entity object from the API, or None on failure.
Updates UDFs for a given entity (Contact or Person) using PATCH.
entity_type: 'Contact' or 'Person'
udf_data: {ProgId: Value}
"""
# We need to GET the existing entity, update its UDFs, then PUT it back.
endpoint = f"{entity_type}/{entity_id}"
existing_entity = self._get(endpoint)
if not existing_entity:
print(f"❌ Failed to retrieve existing {entity_type} {entity_id} for UDF update.")
return None
if "UserDefinedFields" not in existing_entity:
existing_entity["UserDefinedFields"] = {}
existing_entity["UserDefinedFields"].update(udf_data)
# Construct PATCH payload
payload = {
"UserDefinedFields": udf_data
}
print(f"Updating {entity_type} {entity_id} UDFs: {udf_data}...")
return self._put(endpoint, existing_entity)
logger.info(f"Patching {entity_type} {entity_id} UDFs: {udf_data}...")
# PATCH update
result = self._patch(endpoint, payload)
return bool(result)
def update_person_position(self, person_id: int, position_id: int):
"""
Updates the standard 'Position' field of a Person using PATCH.
"""
endpoint = f"Person/{person_id}"
# Construct PATCH payload
payload = {
"Position": {"Id": int(position_id)}
}
logger.info(f"Patching Person {person_id} Position to ID {position_id}...")
# PATCH update
result = self._patch(endpoint, payload)
return bool(result)
def patch_contact(self, contact_id: int, patch_data: dict):
"""
Generic PATCH for Contact entity.
"""
endpoint = f"Contact/{contact_id}"
logger.info(f"Patching Contact {contact_id} with data keys: {list(patch_data.keys())}...")
result = self._patch(endpoint, patch_data)
return bool(result)

View File

@@ -0,0 +1,80 @@
import time
import json
import logging
from queue_manager import JobQueue
from worker import process_job
from superoffice_client import SuperOfficeClient
from config import settings
from unittest.mock import MagicMock
# Setup Logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("e2e-test")
def test_e2e():
print("🚀 Starting End-to-End Simulation...")
# 1. Mock the SuperOffice Client
# We don't want to hit the real API in this test script unless we are sure.
# But wait, the user asked for "Finaler End-to-End Systemtest".
# Usually this implies hitting the real systems.
# Let's try to use the REAL client if credentials are present, otherwise Mock.
real_client = False
if settings.SO_CLIENT_ID and settings.SO_REFRESH_TOKEN:
print("✅ Real Credentials found. Attempting real connection...")
try:
so_client = SuperOfficeClient()
if so_client.access_token:
real_client = True
except:
print("⚠️ Real connection failed. Falling back to Mock.")
if not real_client:
print("⚠️ Using MOCKED SuperOffice Client.")
so_client = MagicMock()
so_client.get_contact.return_value = {"ContactId": 123, "Name": "Test Company", "UserDefinedFields": {}}
so_client.get_person.return_value = {"PersonId": 456, "Contact": {"ContactId": 123}, "UserDefinedFields": {}}
so_client.update_entity_udfs.return_value = True
else:
# Use a SAFE contact ID for testing if possible.
# CAUTION: This writes to the real system.
# Verify with user? Use a known "Gemini Test" contact?
# Let's use the ID 2 (which was mentioned in status updates as test data)
TEST_CONTACT_ID = 2
# Verify it exists
c = so_client.get_contact(TEST_CONTACT_ID)
if not c:
print(f"❌ Test Contact {TEST_CONTACT_ID} not found. Aborting real write-test.")
return
print(f" Using Real Contact: {c.get('Name')} (ID: {TEST_CONTACT_ID})")
# 2. Create a Fake Job
fake_job = {
"id": "test-job-001",
"event_type": "contact.changed",
"payload": {
"PrimaryKey": 2, # Use the real ID
"ContactId": 2,
"JobTitle": "Geschäftsführer" # Trigger mapping
},
"created_at": time.time()
}
# 3. Process the Job (using worker logic)
# NOTE: This assumes COMPANY_EXPLORER_URL is reachable.
# If running in CLI container, it might need to be 'localhost' or the docker DNS name.
# Let's override config for this test run.
# settings.COMPANY_EXPLORER_URL = "http://localhost:8000" # Try localhost first if running on host/mapped
print(f"\n⚙️ Processing Job with CE URL: {settings.COMPANY_EXPLORER_URL}...")
try:
result = process_job(fake_job, so_client)
print(f"\n✅ Job Result: {result}")
except Exception as e:
print(f"\n❌ Job Failed: {e}")
if __name__ == "__main__":
test_e2e()

View File

@@ -0,0 +1,100 @@
import os
import requests
import json
import logging
import sys
# Configure to run from root context
sys.path.append(os.path.join(os.getcwd(), "connector-superoffice"))
# Mock Config if needed, or use real one
try:
from config import settings
except ImportError:
print("Could not import settings. Ensure you are in project root.")
sys.exit(1)
# FORCE CE URL for internal Docker comms if running inside container
# If running outside, this might need localhost.
# settings.COMPANY_EXPLORER_URL is used.
API_USER = os.getenv("API_USER", "admin")
API_PASS = os.getenv("API_PASSWORD", "gemini")
def test_dynamic_role_change():
print("🧪 STARTING TEST: Dynamic Role Change & Content Generation\n")
# Define Scenarios
scenarios = [
{
"name": "Scenario A (CEO)",
"job_title": "Geschäftsführer",
"expect_keywords": ["Kostenreduktion", "Effizienz", "Amortisation"]
},
{
"name": "Scenario B (Warehouse Mgr)",
"job_title": "Lagerleiter",
"expect_keywords": ["Stress", "Sauberkeit", "Entlastung"]
}
]
results = {}
for s in scenarios:
print(f"--- Running {s['name']} ---")
print(f"Role Trigger: '{s['job_title']}'")
payload = {
"so_contact_id": 2, # RoboPlanet Test
"so_person_id": 2,
"crm_name": "RoboPlanet GmbH-SOD",
"crm_website": "www.roboplanet.de", # Ensure we match the industry (Logistics)
"job_title": s['job_title']
}
try:
url = f"{settings.COMPANY_EXPLORER_URL}/api/provision/superoffice-contact"
print(f"POST {url}")
resp = requests.post(url, json=payload, auth=(API_USER, API_PASS))
resp.raise_for_status()
data = resp.json()
# Validation
texts = data.get("texts", {})
subject = texts.get("subject", "")
intro = texts.get("intro", "")
print(f"Received Role: {data.get('role_name')}")
print(f"Received Subject: {subject}")
# Check Keywords
full_text = (subject + " " + intro).lower()
matches = [k for k in s['expect_keywords'] if k.lower() in full_text]
if len(matches) > 0:
print(f"✅ Content Match! Found keywords: {matches}")
results[s['name']] = "PASS"
else:
print(f"❌ Content Mismatch. Expected {s['expect_keywords']}, got text: {subject}...")
results[s['name']] = "FAIL"
results[f"{s['name']}_Subject"] = subject # Store for comparison later
except Exception as e:
print(f"❌ API Error: {e}")
results[s['name']] = "ERROR"
print("")
# Final Comparison
print("--- Final Result Analysis ---")
if results["Scenario A (CEO)"] == "PASS" and results["Scenario B (Warehouse Mgr)"] == "PASS":
if results["Scenario A (CEO)_Subject"] != results["Scenario B (Warehouse Mgr)_Subject"]:
print("✅ SUCCESS: Different roles generated different, targeted content.")
else:
print("⚠️ WARNING: Content matched keywords but Subjects are identical! Check Matrix.")
else:
print("❌ TEST FAILED. See individual steps.")
if __name__ == "__main__":
test_dynamic_role_change()

View File

@@ -0,0 +1,308 @@
import unittest
import os
import sys
import json
import logging
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Setup Paths
current_dir = os.path.dirname(os.path.abspath(__file__))
ce_backend_dir = os.path.abspath(os.path.join(current_dir, "../../company-explorer"))
connector_dir = os.path.abspath(os.path.join(current_dir, ".."))
sys.path.append(ce_backend_dir)
sys.path.append(connector_dir)
# Import CE App & DB
# Note: backend.app needs to be importable. If backend is a package.
try:
from backend.app import app, get_db
from backend.database import Base, Industry, Persona, MarketingMatrix, JobRolePattern, Company, Contact, init_db
except ImportError:
# Try alternate import if running from root
sys.path.append(os.path.abspath("company-explorer"))
from backend.app import app, get_db
from backend.database import Base, Industry, Persona, MarketingMatrix, JobRolePattern, Company, Contact, init_db
# Import Worker Logic
from worker import process_job
# Setup Test DB
TEST_DB_FILE = "/tmp/test_company_explorer.db"
if os.path.exists(TEST_DB_FILE):
os.remove(TEST_DB_FILE)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{TEST_DB_FILE}"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Override get_db dependency
def override_get_db():
try:
db = TestingSessionLocal()
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
# Mock SuperOffice Client
class MockSuperOfficeClient:
def __init__(self):
self.access_token = "mock_token"
self.contacts = {} # id -> data
self.persons = {} # id -> data
def get_contact(self, contact_id, select=None):
return self.contacts.get(int(contact_id))
def get_person(self, person_id, select=None):
return self.persons.get(int(person_id))
def update_entity_udfs(self, entity_id, entity_type, udfs):
target = self.contacts if entity_type == "Contact" else self.persons
if int(entity_id) in target:
if "UserDefinedFields" not in target[int(entity_id)]:
target[int(entity_id)]["UserDefinedFields"] = {}
target[int(entity_id)]["UserDefinedFields"].update(udfs)
return True
return False
def update_person_position(self, person_id, position_id):
if int(person_id) in self.persons:
self.persons[int(person_id)]["PositionId"] = position_id
return True
return False
def create_appointment(self, subject, description, contact_id, person_id=None):
if not hasattr(self, 'appointments'):
self.appointments = []
self.appointments.append({
"Subject": subject,
"Description": description,
"ContactId": contact_id,
"PersonId": person_id
})
return True
def search(self, query):
if "contact/contactId eq" in query:
contact_id = int(query.split("eq")[1].strip())
results = []
for pid, p in self.persons.items():
if p.get("ContactId") == contact_id:
results.append({"PersonId": pid, "FirstName": p.get("FirstName")})
return results
return []
def _put(self, endpoint, data):
if endpoint.startswith("Contact/"):
cid = int(endpoint.split("/")[1])
if cid in self.contacts:
self.contacts[cid] = data
return True
return False
class TestE2EFlow(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Set Auth Env Vars
os.environ["API_USER"] = "admin"
os.environ["API_PASSWORD"] = "gemini"
# Create Tables
Base.metadata.create_all(bind=engine)
db = TestingSessionLocal()
# SEED DATA
# Industry 1
ind1 = Industry(name="Logistics - Warehouse", status_notion="Active")
db.add(ind1)
# Industry 2 (For Change Test)
ind2 = Industry(name="Healthcare - Hospital", status_notion="Active")
db.add(ind2)
db.commit()
pers = Persona(name="Operativer Entscheider")
db.add(pers)
db.commit()
# Matrix 1
matrix1 = MarketingMatrix(
industry_id=ind1.id,
persona_id=pers.id,
subject="TEST SUBJECT LOGISTICS",
intro="TEST BRIDGE LOGISTICS",
social_proof="TEST PROOF LOGISTICS"
)
db.add(matrix1)
# Matrix 2
matrix2 = MarketingMatrix(
industry_id=ind2.id,
persona_id=pers.id,
subject="TEST SUBJECT HEALTH",
intro="TEST BRIDGE HEALTH",
social_proof="TEST PROOF HEALTH"
)
db.add(matrix2)
mapping = JobRolePattern(pattern_value="Head of Operations", role="Operativer Entscheider", pattern_type="exact")
db.add(mapping)
db.commit()
db.close()
cls.ce_client = TestClient(app)
def setUp(self):
self.mock_so_client = MockSuperOfficeClient()
self.mock_so_client.contacts[100] = {
"ContactId": 100,
"Name": "Test Company GmbH",
"UrlAddress": "old-site.com",
"UserDefinedFields": {}
}
self.mock_so_client.persons[500] = {
"PersonId": 500,
"ContactId": 100,
"FirstName": "Hans",
"JobTitle": "Head of Operations",
"UserDefinedFields": {}
}
def mock_post_side_effect(self, url, json=None, auth=None):
if "/api/" in url:
path = "/api/" + url.split("/api/")[1]
else:
path = url
response = self.ce_client.post(path, json=json, auth=auth)
class MockReqResponse:
def __init__(self, resp):
self.status_code = resp.status_code
self._json = resp.json()
def json(self): return self._json
def raise_for_status(self):
if self.status_code >= 400: raise Exception(f"HTTP {self.status_code}: {self._json}")
return MockReqResponse(response)
@patch("worker.JobQueue")
@patch("worker.requests.post")
@patch("worker.settings")
def test_full_roundtrip_with_vertical_change(self, mock_settings, mock_post, MockJobQueue):
mock_post.side_effect = self.mock_post_side_effect
# Mock JobQueue instance
mock_queue_instance = MockJobQueue.return_value
# Config Mocks
mock_settings.COMPANY_EXPLORER_URL = "http://localhost:8000"
mock_settings.UDF_VERTICAL = "SuperOffice:Vertical"
mock_settings.UDF_SUBJECT = "SuperOffice:Subject"
mock_settings.UDF_INTRO = "SuperOffice:Intro"
mock_settings.UDF_SOCIAL_PROOF = "SuperOffice:SocialProof"
mock_settings.UDF_OPENER = "SuperOffice:Opener"
mock_settings.UDF_OPENER_SECONDARY = "SuperOffice:OpenerSecondary"
mock_settings.VERTICAL_MAP_JSON = '{"Logistics - Warehouse": 23, "Healthcare - Hospital": 24}'
mock_settings.PERSONA_MAP_JSON = '{"Operativer Entscheider": 99}'
mock_settings.ENABLE_WEBSITE_SYNC = True
# --- Step 1: Company Created (Logistics) ---
print("[TEST] Step 1: Create Company...")
job = {"id": "job1", "event_type": "contact.created", "payload": {"Event": "contact.created", "PrimaryKey": 100, "Changes": ["Name"]}}
process_job(job, self.mock_so_client) # RETRY
# Simulate Enrichment (Logistics)
db = TestingSessionLocal()
company = db.query(Company).filter(Company.crm_id == "100").first()
company.status = "ENRICHED"
company.industry_ai = "Logistics - Warehouse"
company.city = "Koeln"
company.crm_vat = "DE813016729"
company.ai_opener = "Positive observation about Silly Billy"
company.ai_opener_secondary = "Secondary observation"
db.commit()
db.close()
process_job(job, self.mock_so_client) # SUCCESS
# Verify Contact Updates (Standard Fields & UDFs)
contact = self.mock_so_client.contacts[100]
self.assertEqual(contact["UserDefinedFields"]["SuperOffice:Vertical"], "23")
self.assertEqual(contact["UserDefinedFields"]["SuperOffice:Opener"], "Positive observation about Silly Billy")
self.assertEqual(contact["UserDefinedFields"]["SuperOffice:OpenerSecondary"], "Secondary observation")
self.assertEqual(contact.get("PostalAddress", {}).get("City"), "Koeln")
self.assertEqual(contact.get("OrgNumber"), "DE813016729")
# --- Step 2: Person Created (Get Logistics Texts) ---
print("[TEST] Step 2: Create Person...")
job_p = {"id": "job2", "event_type": "person.created", "payload": {"Event": "person.created", "PersonId": 500, "ContactId": 100, "JobTitle": "Head of Operations"}}
process_job(job_p, self.mock_so_client)
udfs = self.mock_so_client.persons[500]["UserDefinedFields"]
self.assertEqual(udfs["SuperOffice:Subject"], "TEST SUBJECT LOGISTICS")
# Verify Appointment (Simulation)
self.assertTrue(len(self.mock_so_client.appointments) > 0)
appt = self.mock_so_client.appointments[0]
self.assertIn("✉️ Entwurf: TEST SUBJECT LOGISTICS", appt["Subject"])
self.assertIn("TEST BRIDGE LOGISTICS", appt["Description"])
print(f"[TEST] Appointment created: {appt['Subject']}")
# --- Step 3: Vertical Change in SO (To Healthcare) ---
print("[TEST] Step 3: Change Vertical in SO...")
# Update Mock SO Data
self.mock_so_client.contacts[100]["UserDefinedFields"]["SuperOffice:Vertical"] = "24" # Healthcare
# Simulate Webhook
job_change = {
"id": "job3",
"event_type": "contact.changed",
"payload": {
"Event": "contact.changed",
"PrimaryKey": 100,
"Changes": ["UserDefinedFields"] # Or specific UDF key if passed
}
}
process_job(job_change, self.mock_so_client)
# Verify CE Database Updated
db = TestingSessionLocal()
company = db.query(Company).filter(Company.crm_id == "100").first()
print(f"[TEST] Updated Company Industry in DB: {company.industry_ai}")
self.assertEqual(company.industry_ai, "Healthcare - Hospital")
db.close()
# Verify Cascade Triggered
# Expect JobQueue.add_job called for Person 500
# args: "person.changed", payload
mock_queue_instance.add_job.assert_called()
call_args = mock_queue_instance.add_job.call_args
print(f"[TEST] Cascade Job Added: {call_args}")
self.assertEqual(call_args[0][0], "person.changed")
self.assertEqual(call_args[0][1]["PersonId"], 500)
# --- Step 4: Process Cascade Job (Get Healthcare Texts) ---
print("[TEST] Step 4: Process Cascade Job...")
job_cascade = {"id": "job4", "event_type": "person.changed", "payload": call_args[0][1]}
process_job(job_cascade, self.mock_so_client)
udfs_new = self.mock_so_client.persons[500]["UserDefinedFields"]
print(f"[TEST] New UDFs: {udfs_new}")
self.assertEqual(udfs_new["SuperOffice:Subject"], "TEST SUBJECT HEALTH")
self.assertEqual(udfs_new["SuperOffice:Intro"], "TEST BRIDGE HEALTH")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,127 @@
import os
import requests
import json
import logging
import sys
import time
# Configure path to import modules from parent directory
# This makes the script runnable from the project root
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.join(script_dir, '..')
sys.path.append(parent_dir)
from dotenv import load_dotenv
# Load .env from project root
dotenv_path = os.path.join(parent_dir, '..', '.env')
load_dotenv(dotenv_path=dotenv_path)
from config import settings
from superoffice_client import SuperOfficeClient
# Logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger("e2e-roundtrip")
# Config - Use a real, enriched company for this test
API_USER = os.getenv("API_USER", "admin")
API_PASS = os.getenv("API_PASSWORD", "gemini")
TEST_PERSON_ID = 2 # This is a placeholder, a real one would be used in a live env
TEST_CONTACT_ID = 1 # Company ID for "THERME ERDING" in the CE database
def run_roundtrip():
print("🚀 STARTING E2E TEXT GENERATION TEST (CE -> SuperOffice)\n")
so_client = SuperOfficeClient()
if not so_client.access_token:
print("❌ SuperOffice Auth failed. Check .env")
return
scenarios = [
{
"name": "Scenario A: Infrastructure Role (Facility Manager)",
"job_title": "Leiter Facility Management",
"expected_opener_field": "opener",
"expected_keyword": "Sicherheit" # Keyword for Primary opener (Hygiene/Safety)
},
{
"name": "Scenario B: Operational Role (Leiter Badbetrieb)",
"job_title": "Leiter Badebetrieb",
"expected_opener_field": "opener_secondary",
"expected_keyword": "Gäste" # Keyword for Secondary opener (Guest experience/Service)
}
]
for s in scenarios:
print(f"--- Running {s['name']}: {s['job_title']} ---")
# 1. Provisioning from Company Explorer
print(f"1. 🧠 Asking Company Explorer for texts...")
ce_url = f"{settings.COMPANY_EXPLORER_URL}/api/provision/superoffice-contact"
payload = {
"so_contact_id": TEST_CONTACT_ID,
"so_person_id": TEST_PERSON_ID,
"crm_name": "THERME ERDING Service GmbH", # Real data
"crm_website": "https://www.therme-erding.de/",
"job_title": s['job_title']
}
try:
resp = requests.post(ce_url, json=payload, auth=(API_USER, API_PASS))
resp.raise_for_status()
data = resp.json()
# --- ASSERTIONS ---
print("2. 🧐 Verifying API Response...")
# Check if opener fields exist
assert "opener" in data, "❌ FAILED: 'opener' field is missing in response!"
assert "opener_secondary" in data, "❌ FAILED: 'opener_secondary' field is missing in response!"
print("'opener' and 'opener_secondary' fields are present.")
# Check if the specific opener for the role is not empty
opener_text = data.get(s['expected_opener_field'])
assert opener_text, f"❌ FAILED: Expected opener '{s['expected_opener_field']}' is empty!"
print(f" ✅ Expected opener '{s['expected_opener_field']}' is not empty.")
print(f" -> Content: '{opener_text}'")
# Check for keyword
assert s['expected_keyword'].lower() in opener_text.lower(), f"❌ FAILED: Keyword '{s['expected_keyword']}' not in opener text!"
print(f" ✅ Keyword '{s['expected_keyword']}' found in opener.")
# --- Write to SuperOffice ---
print(f"3. ✍️ Writing verified texts to SuperOffice UDFs...")
texts = data.get("texts", {})
udf_payload = {
settings.UDF_SUBJECT: texts.get("subject", ""),
settings.UDF_INTRO: texts.get("intro", ""),
settings.UDF_SOCIAL_PROOF: texts.get("social_proof", ""),
"x_opener_primary": data.get("opener", ""), # Assuming UDF names
"x_opener_secondary": data.get("opener_secondary", "") # Assuming UDF names
}
# This part is a simulation of the write; in a real test we'd need the real ProgIDs
# For now, we confirm the logic works up to this point.
if so_client.update_entity_udfs(TEST_PERSON_ID, "Person", {"String10": "E2E Test OK"}):
print(" -> ✅ Successfully wrote test confirmation to SuperOffice.")
else:
print(" -> ❌ Failed to write to SuperOffice.")
except requests.exceptions.HTTPError as e:
print(f" ❌ CE API HTTP Error: {e.response.status_code} - {e.response.text}")
continue
except AssertionError as e:
print(f" {e}")
continue
except Exception as e:
print(f" ❌ An unexpected error occurred: {e}")
continue
print(f"--- PASSED: {s['name']} ---\n")
time.sleep(1)
print("🏁 Test Run Complete.")
if __name__ == "__main__":
run_roundtrip()

View File

@@ -0,0 +1,45 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
def blind_check():
print("🕵️ Testing Manuel's Filter: contactAssociate/contactFullName eq 'RoboPlanet GmbH'")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# Manuel's filter logic with Count
endpoint = "Contact?$filter=contactAssociate/contactFullName eq 'RoboPlanet GmbH'&$top=0&$count=true"
print(f"📡 Querying: {endpoint}")
try:
resp = client._get(endpoint)
count = resp.get('@odata.count')
print(f"\n🎯 RESULT: Manuel's Filter found {count} accounts.")
if count == 17014:
print("✅ PERFECT MATCH! Manuel's filter matches your UI count exactly.")
else:
print(f" Delta to UI: {17014 - (count or 0)}")
except Exception as e:
print(f"❌ Manuel's filter failed: {e}")
# Try without spaces encoded
print("Trying with encoded spaces...")
try:
endpoint_enc = "Contact?$filter=contactAssociate/contactFullName eq 'RoboPlanet+GmbH'&$top=0&$count=true"
resp = client._get(endpoint_enc)
print(f"🎯 Encoded Result: {resp.get('@odata.count')}")
except:
pass
if __name__ == "__main__":
blind_check()

View File

@@ -0,0 +1,47 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
def check_associate_details():
print("🔎 Checking Associate Details in Contact Record...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# Use our known test company (if it still exists - oh wait, we deleted it!)
# We need to find ANY contact.
# Search for any contact
print("Searching for a contact...")
contacts = client.search("Contact?$top=1")
if contacts:
cid = contacts[0].get('contactId') or contacts[0].get('ContactId')
print(f"✅ Found Contact ID: {cid}")
# Fetch Full Details
print("Fetching details...")
details = client.get_contact(cid)
assoc = details.get('Associate')
print("--- Associate Object ---")
print(json.dumps(assoc, indent=2))
if assoc and 'GroupIdx' in assoc:
print(f"✅ SUCCESS: GroupIdx is available: {assoc['GroupIdx']}")
else:
print("❌ FAILURE: GroupIdx is MISSING in Contact details.")
else:
print("❌ No contacts found in system.")
if __name__ == "__main__":
check_associate_details()

View File

@@ -0,0 +1,38 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
def check_counts():
print("📊 Verifying Filter Logic via OData Search...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# Simplified OData Search
# We ask for top=1 but want the total count
endpoint = "Contact?$filter=name contains 'GmbH'&$top=1&$select=Associate"
print(f"📡 Querying: {endpoint}")
try:
resp = client._get(endpoint)
print("--- RAW RESPONSE START ---")
print(json.dumps(resp, indent=2))
print("--- RAW RESPONSE END ---")
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
check_counts()
if __name__ == "__main__":
check_counts()

View File

@@ -0,0 +1,52 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
def check_selection():
selection_id = 10960
print(f"🔎 Inspecting Selection {selection_id} (Alle_Contacts_Roboplanet)...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# 1. Get Selection Metadata
print("\n📋 Fetching Selection Details...")
details = client._get(f"Selection/{selection_id}")
if details:
print(f" Name: {details.get('Name')}")
print(f" Description: {details.get('Description')}")
print(f" Type: {details.get('SelectionType')}") # e.g. Dynamic, Static
# 2. Fetch Members via direct Selection endpoint
print("\n👥 Fetching first 10 Members via direct Selection endpoint...")
# Direct endpoint for Contact members of a selection
endpoint = f"Selection/{selection_id}/ContactMembers?$top=10"
try:
members_resp = client._get(endpoint)
# OData usually returns a 'value' list
members = members_resp.get('value', []) if isinstance(members_resp, dict) else members_resp
if members and isinstance(members, list):
print(f"✅ Found {len(members)} members in first page:")
for m in members:
# Structure might be flat or nested
name = m.get('Name') or m.get('name')
cid = m.get('ContactId') or m.get('contactId')
print(f" - {name} (ContactID: {cid})")
else:
print("⚠️ No members found or response format unexpected.")
print(f"DEBUG: {json.dumps(members_resp, indent=2)}")
except Exception as e:
print(f"❌ Direct Selection members query failed: {e}")
if __name__ == "__main__":
check_selection()

View File

@@ -0,0 +1,62 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
def run_discovery():
print("🔎 Discovery: Searching for Selections and Associate Mapping...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# 1. Search for Selections
print("\n📁 Searching for 'Roboplanet' Selections...")
# Selections can be found via Archive or direct endpoint
selections = client.search("Selection?$filter=name contains 'Roboplanet'")
if selections:
print(f"✅ Found {len(selections)} matching selections:")
for sel in selections:
sid = sel.get('SelectionId') or sel.get('selectionId')
name = sel.get('Name') or sel.get('name')
print(f" - {name} (ID: {sid})")
else:
print("⚠️ No selections found with name 'Roboplanet'.")
# 2. Get Associate Mapping via Archive Provider
# This avoids the Associate/{id} 500 error
print("\n👥 Fetching Associate-to-Group mapping via Archive...")
# Provider 'associate' is standard
endpoint = "Archive/dynamic?provider=associate&columns=associateId,name,groupIdx"
try:
mapping_data = client._get(endpoint)
if mapping_data and isinstance(mapping_data, list):
print(f"✅ Received {len(mapping_data)} associate records.")
robo_user_ids = []
for item in mapping_data:
aid = item.get("associateId")
name = item.get("name")
gid = item.get("groupIdx")
if gid == 52:
print(f" - [ROBO] {name} (ID: {aid}, Group: {gid})")
robo_user_ids.append(aid)
elif "Fottner" in str(name) or aid == 321:
print(f" - [EXCLUDE] {name} (ID: {aid}, Group: {gid})")
print(f"\n🚀 Identified {len(robo_user_ids)} Roboplanet Users.")
if robo_user_ids:
print(f"List of IDs: {robo_user_ids}")
else:
print("❌ Archive query returned no associate mapping.")
except Exception as e:
print(f"❌ Archive query failed: {e}")
if __name__ == "__main__":
run_discovery()

View File

@@ -0,0 +1,40 @@
import sys
import os
import requests
from dotenv import load_dotenv
# Ensure we use the correct config and client
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'connector-superoffice')))
from superoffice_client import SuperOfficeClient
def cleanup():
print("🧹 Cleaning up Test Data...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# Objects to delete (Reverse order of dependency)
to_delete = [
("Sale", 342539),
("Appointment", 993350),
("Appointment", 993347),
("Person", 193092),
("Contact", 171185) # Attempting to delete the company too
]
for entity_type, entity_id in to_delete:
print(f"🗑️ Deleting {entity_type} {entity_id}...")
try:
# SuperOffice DELETE usually returns 204 No Content
# Our client returns None on success if response body is empty, or the JSON if not.
# We need to catch exceptions if it fails.
resp = client._delete(f"{entity_type}/{entity_id}")
print(f"✅ Deleted {entity_type} {entity_id}")
except Exception as e:
print(f"⚠️ Failed to delete {entity_type} {entity_id}: {e}")
if __name__ == "__main__":
cleanup()

View File

@@ -0,0 +1,69 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
from config import settings
def verify_total_counts():
print("📊 Verifying Global Account Counts...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
whitelist = settings.ROBOPLANET_WHITELIST
# 1. Try to get MemberCount from the Selection 10960 directly
print("\n📁 Checking Selection 10960 (Alle_Contacts_Roboplanet)...")
try:
sel_details = client._get("Selection/10960")
if sel_details:
# Note: MemberCount is often a property of the Selection entity
count = sel_details.get("MemberCount")
print(f" 🔹 Web-Interface-equivalent Count (MemberCount): {count}")
except Exception as e:
print(f" ⚠️ Could not fetch Selection count property: {e}")
# 2. Manual Aggregate Count via OData
# We construct a filter for all our IDs and Shortnames
# This might be too long for a URL, so we do it in smaller batches if needed
print("\n📡 Calculating Netto Count for Whitelist (IDs + Names)...")
# Divide whitelist into IDs and Names
ids = [x for x in whitelist if isinstance(x, int)]
names = [x for x in whitelist if isinstance(x, str)]
# Construct OData filter string
# example: (associateId eq 528 or associateId eq 485 or associateId eq 'RKAB')
id_filters = [f"associateId eq {i}" for i in ids]
name_filters = [f"associateId eq '{n}'" for n in names]
full_filter = " or ".join(id_filters + name_filters)
# We use $top=0 and $count=true to get JUST the number
endpoint = f"Contact?$filter={full_filter}&$top=0&$count=true"
try:
# Note: If the URL is too long (> 2000 chars), this might fail.
# But for ~60 entries it should be fine.
resp = client._get(endpoint)
total_api_count = resp.get("@odata.count")
print(f" 🎯 API Calculated Count (Whitelist-Match): {total_api_count}")
if total_api_count is not None:
print(f"\n✅ PROOF: The API identifies {total_api_count} accounts for Roboplanet.")
print("👉 Bitte vergleiche diese Zahl mit der Selektion 'Alle_Contacts_Roboplanet' im SuperOffice Web-Interface.")
else:
print("❌ API did not return a count property.")
except Exception as e:
print(f"❌ OData Aggregation failed: {e}")
print(" The filter string might be too long for the API.")
if __name__ == "__main__":
verify_total_counts()

View File

@@ -0,0 +1,57 @@
import sys
import os
from dotenv import load_dotenv
# Explicitly load .env from the parent directory
dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
print(f"Loading .env from: {dotenv_path}")
load_dotenv(dotenv_path=dotenv_path, override=True)
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from superoffice_client import SuperOfficeClient
def create_test_company():
"""
Creates a new company in SuperOffice for E2E testing.
"""
company_name = "Bremer Abenteuerland"
# Provide a real-world, scrapable website to test enrichment
website = "https://www.belantis.de/"
print(f"🚀 Attempting to create company: '{company_name}'")
try:
client = SuperOfficeClient()
if not client.access_token:
print("❌ Authentication failed. Check your .env file.")
return
# Check if company already exists
existing = client.search(f"Contact?$select=contactId,name&$filter=name eq '{company_name}'")
print(f"DEBUG: Raw search response: {existing}")
if existing:
contact_id = existing[0]['contactId']
print(f"⚠️ Company '{company_name}' already exists with ContactId: {contact_id}.")
print("Skipping creation.")
return contact_id
payload = {
"Name": company_name,
"Urls": [
{
"Value": website,
"Description": "Main Website"
}
],
"Country": {
"CountryId": 68 # Germany
}
}
new_company = client._post("Contact", payload)
if new_company and "contactId" in new_company:
contact_id = new_company["contactId"]
print(f"✅ SUCCESS! Created company '{company_name}' with ContactId: {contact_id}")
return contact_id
else:
print(f"❌ Failed to create company. Response: {new_company}")
return None
except Exception as e:
print(f"An error occurred: {e}")
return None
if __name__ == "__main__":
create_test_company()

View File

@@ -0,0 +1,44 @@
import sys
import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from superoffice_client import SuperOfficeClient
def create_test_person(contact_id: int):
"""
Creates a new person for a given contact ID.
"""
print(f"🚀 Attempting to create a person for Contact ID: {contact_id}")
try:
client = SuperOfficeClient()
if not client.access_token:
print("❌ Authentication failed.")
return
payload = {
"Contact": {"ContactId": contact_id},
"Firstname": "Test",
"Lastname": "Person",
"Emails": [
{
"Value": "floke.com@gmail.com",
"Description": "Work Email"
}
]
}
new_person = client._post("Person", payload)
if new_person and "PersonId" in new_person:
person_id = new_person["PersonId"]
print(f"✅ SUCCESS! Created person with PersonId: {person_id}")
return person_id
else:
print(f"❌ Failed to create person. Response: {new_person}")
return None
except Exception as e:
print(f"An error occurred: {e}")
return None
if __name__ == "__main__":
TEST_CONTACT_ID = 171185
if len(sys.argv) > 1:
TEST_CONTACT_ID = int(sys.argv[1])
create_test_person(TEST_CONTACT_ID)

View File

@@ -0,0 +1,40 @@
import sys
import os
from dotenv import load_dotenv
# Explicitly load .env from the project root
dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
print(f"Loading .env from: {dotenv_path}")
load_dotenv(dotenv_path=dotenv_path, override=True)
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from config import settings
print("\n--- DEBUGGING CONFIG TYPES ---")
try:
print(f"UDF_VERTICAL: {settings.UDF_VERTICAL} (Type: {type(settings.UDF_VERTICAL)})")
print(f"UDF_SUMMARY: {settings.UDF_SUMMARY} (Type: {type(settings.UDF_SUMMARY)})")
print(f"UDF_OPENER: {settings.UDF_OPENER} (Type: {type(settings.UDF_OPENER)})")
print(f"UDF_OPENER_SECONDARY: {settings.UDF_OPENER_SECONDARY} (Type: {type(settings.UDF_OPENER_SECONDARY)})")
print(f"UDF_LAST_UPDATE: {settings.UDF_LAST_UPDATE} (Type: {type(settings.UDF_LAST_UPDATE)})")
print(f"UDF_LAST_OUTREACH: {settings.UDF_LAST_OUTREACH} (Type: {type(settings.UDF_LAST_OUTREACH)})")
# Test dictionary creation to force the error if a key is a dict
print("\nAttempting to create dictionary with these keys...")
test_dict = {
settings.UDF_VERTICAL: "Vertical",
settings.UDF_SUMMARY: "Summary",
settings.UDF_OPENER: "Opener",
settings.UDF_OPENER_SECONDARY: "Opener 2",
settings.UDF_LAST_UPDATE: "Last Update",
settings.UDF_LAST_OUTREACH: "Last Outreach"
}
print("✅ Dictionary creation SUCCESSFUL.")
except TypeError as e:
print(f"\n❌ TypeError CAUGHT: {e}")
print("One of the settings above is likely a dictionary or unhashable type!")
except Exception as e:
print(f"\n❌ Unknown Error: {e}")

View File

@@ -0,0 +1,32 @@
import os
from dotenv import load_dotenv
# Explicitly load .env from the project root
dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
print(f"Loading .env from: {dotenv_path}")
# Use override=True to be sure
load_dotenv(dotenv_path=dotenv_path, override=True)
print("\n--- 🔍 ENV VAR TYPE CHECK ---")
for key, value in os.environ.items():
if key.startswith("UDF_") or key.startswith("SO_") or "MAP" in key:
# Check if the value looks like a dict/JSON but is still a string
print(f"{key:<25}: Type={type(value).__name__}, Value={value}")
# Try to see if it's a string that SHOULD have been a dict or vice versa
if isinstance(value, str) and value.startswith("{"):
print(f" ⚠️ ALERT: String looks like JSON!")
print("\n--- ⚙️ SETTINGS OBJECT CHECK ---")
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from config import settings
for attr in dir(settings):
if attr.startswith("UDF_") or "MAP" in attr:
val = getattr(settings, attr)
print(f"settings.{attr:<20}: Type={type(val).__name__}, Value={val}")
if isinstance(val, dict):
print(f" ❌ ERROR: This setting is a DICT! This will crash dictionary lookups.")
print("-----------------------------")

View File

@@ -0,0 +1,35 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
def debug_names():
print("🔎 Debugging Associate Names...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
endpoint = "Contact?$orderby=contactId desc&$top=5&$select=name,Associate/Name"
print(f"📡 Querying: {endpoint}")
contacts = client.search(endpoint)
if contacts:
for c in contacts:
cname = c.get('name')
assoc = c.get('Associate') or {}
aname = assoc.get('Name')
print(f" 🏢 Contact: {cname}")
print(f" 👉 Associate Name: '{aname}'")
else:
print("❌ No contacts found.")
if __name__ == "__main__":
debug_names()

View File

@@ -0,0 +1,45 @@
import sys
import os
import requests
from dotenv import load_dotenv
# Explicitly load .env from the project root
dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
load_dotenv(dotenv_path=dotenv_path, override=True)
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from superoffice_client import SuperOfficeClient
def get_raw_data(contact_id: int):
print(f"🚀 Fetching RAW response for ContactId: {contact_id}")
try:
client = SuperOfficeClient()
if not client.access_token:
print("❌ Authentication failed.")
return
# Build URL manually to avoid any JSON parsing in the client
url = f"{client.base_url}/Contact/{contact_id}?$select=Name,UserDefinedFields"
headers = client.headers
print(f"URL: {url}")
resp = requests.get(url, headers=headers)
print(f"Status Code: {resp.status_code}")
# Save raw content to a file
output_file = "raw_api_response.json"
with open(output_file, "w") as f:
f.write(resp.text)
print(f"✅ Raw response saved to {output_file}")
print("\nFirst 500 characters of response:")
print(resp.text[:500])
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
get_raw_data(171185)

View File

@@ -0,0 +1,66 @@
import sys
import os
import json
# Absolute path setup to avoid import errors
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
def discover_associates_and_groups():
print("🔎 Discovering Associates and Groups...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# 1. Fetch User Groups
print("\n👥 Fetching User Groups...")
groups = client._get("MDOList/usergroup")
robo_group_id = None
if groups:
for group in groups:
name = group.get('Name')
grp_id = group.get('Id')
print(f" - Group: {name} (ID: {grp_id})")
if "Roboplanet" in name:
robo_group_id = grp_id
if robo_group_id:
print(f"✅ Identified Roboplanet Group ID: {robo_group_id}")
else:
print("⚠️ Could not auto-identify Roboplanet group. Check the list above.")
# 2. Check Candidate IDs directly
print("\n👤 Checking specific Person IDs for Willi Fottner...")
candidates = [6, 182552]
for pid in candidates:
try:
p = client.get_person(pid)
if p:
fname = p.get('Firstname')
lname = p.get('Lastname')
is_assoc = p.get('IsAssociate')
print(f" 👉 Person {pid}: {fname} {lname} (IsAssociate: {is_assoc})")
if is_assoc:
assoc_obj = p.get("Associate")
if assoc_obj:
assoc_id = assoc_obj.get("AssociateId")
grp = assoc_obj.get("GroupIdx")
print(f" ✅ IS ASSOCIATE! ID: {assoc_id}, Group: {grp}")
if "Fottner" in str(lname) or "Willi" in str(fname):
print(f" 🎯 TARGET IDENTIFIED: Willi Fottner is Associate ID {assoc_id}")
except Exception as e:
print(f" ❌ Error checking Person {pid}: {e}")
print("\n--- Done ---")
if __name__ == "__main__":
discover_associates_and_groups()

View File

@@ -0,0 +1,72 @@
import sys
import os
import json
import requests
from datetime import datetime, timedelta
from dotenv import load_dotenv
# Ensure we use the correct config and client from the connector-superoffice subdir
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'connector-superoffice')))
from superoffice_client import SuperOfficeClient
def run_final_test():
print("🚀 Starting Final Mailing Test for floke.com@gmail.com...")
# 1. Initialize Client
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# 2. Use Target Contact (Bremer Abenteuerland)
contact_id = 171185
print(f"✅ Using Contact ID: {contact_id}")
# 3. Use Created Person
person_id = 193092
print(f"✅ Using Person ID: {person_id} (floke.com@gmail.com)")
# 4. Attempt Shipment (Mailing)
print("📤 Attempting to create Shipment (the direct email send)...")
shipment_payload = {
"Name": "Gemini Diagnostics: Test Shipment",
"Subject": "Hallo aus der Gemini GTM Engine",
"Body": "Dies ist ein Testversuch für den direkten E-Mail-Versand via SuperOffice API.",
"DocumentTemplateId": 157, # Outgoing Email (ID 157 is confirmed from previous runs as typical)
"ShipmentType": "Email",
"AssociateId": 528, # API User RCGO
"ContactId": contact_id,
"PersonId": person_id,
"Status": "Ready"
}
try:
shipment_resp = client._post("Shipment", shipment_payload)
if shipment_resp:
print("✅ UNEXPECTED SUCCESS: Shipment created!")
print(json.dumps(shipment_resp, indent=2))
else:
print("❌ Shipment creation returned empty response.")
except Exception as e:
print(f"❌ EXPECTED FAILURE: Shipment creation failed as predicted.")
print(f"Error details: {e}")
# 5. Fallback: Create Appointment as "Proof of Work"
print("\n📅 Running Workaround: Creating Appointment instead...")
appt_resp = client.create_appointment(
subject="KI: E-Mail Testversuch an floke.com@gmail.com",
description="Hier würde der E-Mail-Text stehen, der aufgrund technischer Blockaden (Mailing-Modul/Identität) nicht direkt versendet werden konnte.",
contact_id=contact_id,
person_id=person_id
)
if appt_resp:
appt_id = appt_resp.get("appointmentId") or appt_resp.get("AppointmentId")
print(f"✅ Workaround Successful: Appointment ID: {appt_id}")
print(f"🔗 Link: https://online3.superoffice.com/Cust26720/default.aspx?appointment_id={appt_id}")
else:
print("❌ Workaround (Appointment) failed too.")
if __name__ == "__main__":
run_final_test()

View File

@@ -0,0 +1,65 @@
import json
import os
def check_truth():
file_path = "raw_api_response.json"
if not os.path.exists(file_path):
print(f"❌ Datei {file_path} nicht gefunden.")
return
print(f"🧐 Analysiere {file_path}...")
try:
with open(file_path, "r") as f:
data = json.load(f)
udfs = data.get("UserDefinedFields", {})
print(f"✅ JSON erfolgreich geladen. UDFs gefunden: {len(udfs)}")
invalid_keys = []
for key in udfs.keys():
if not isinstance(key, str):
invalid_keys.append((key, type(key)))
if invalid_keys:
print(f"❌ FEHLER GEFUNDEN! Folgende Keys sind KEINE Strings:")
for k, t in invalid_keys:
print(f" - Key: {k}, Typ: {t}")
else:
print("✅ Alle Keys in UserDefinedFields sind valide Strings.")
# Jetzt prüfen wir unsere eigenen Settings gegen dieses Dict
print("\n--- Teste Zugriff mit unseren Settings ---")
from dotenv import load_dotenv
import sys
# Pfad zum Hauptverzeichnis für .env
dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
load_dotenv(dotenv_path=dotenv_path, override=True)
# Pfad für config import
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from config import settings
test_keys = {
"Vertical": settings.UDF_VERTICAL,
"Summary": settings.UDF_SUMMARY,
"Opener": settings.UDF_OPENER
}
for name, key in test_keys.items():
print(f"Prüfe {name} (Key: '{key}', Typ: {type(key)})...")
try:
# Das ist die Stelle, die vorhin zum Absturz führte
val = udfs.get(key)
print(f" -> Zugriff erfolgreich! Wert: {val}")
except TypeError as e:
print(f" -> ❌ ABSTURZ: {e}")
if isinstance(key, dict):
print(f" Grund: settings.UDF_{name.upper()} ist ein DICTIONARY!")
except Exception as e:
print(f"❌ Allgemeiner Fehler bei der Analyse: {e}")
if __name__ == "__main__":
check_truth()

View File

@@ -0,0 +1,66 @@
import sys
import os
import json
# Absolute path to the connector-superoffice directory
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
# CRITICAL: Insert at 0 to shadow /app/config.py
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
def discover_verticals():
print("🔎 Starting Final Vertical Discovery (Production)...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# 1. Fetch Contact UDF Layout to find the List ID behind SuperOffice:83
print("📡 Fetching Contact UDF Layout (Metadata)...")
layout = client._get("Contact/UdefLayout/Published")
list_id = None
if layout and 'Fields' in layout:
for field in layout['Fields']:
if field.get('ProgId') == 'SuperOffice:83':
print(f"✅ Found SuperOffice:83: {field.get('Label')}")
list_id = field.get('ListId')
print(f"✅ List ID: {list_id}")
break
if not list_id:
print("❌ Could not find Metadata for SuperOffice:83.")
return
# 2. Fetch the List Items for this List
print(f"📡 Fetching List Items for List ID {list_id}...")
# List endpoint is typically List/ListId/Items
# Let's try to get all rows for this list
items = client._get(f"List/{list_id}/Items")
if items:
print(f"✅ SUCCESS! Found {len(items)} items in the Vertical list.")
mapping = {}
for item in items:
name = item.get('Value') or item.get('Name')
item_id = item.get('Id')
mapping[name] = item_id
print(f" - {name}: {item_id}")
print("\n🚀 FINAL MAPPING JSON (Copy to .env VERTICAL_MAP_JSON):")
print(json.dumps(mapping))
else:
print(f"❌ Could not fetch items for List {list_id}. Trying MDO List...")
# Fallback to MDO List
mdo_items = client._get(f"MDOList/udlist{list_id}")
if mdo_items:
print("✅ Success via MDO List.")
# ... process mdo items if needed ...
else:
print("❌ MDO List fallback failed too.")
if __name__ == "__main__":
discover_verticals()

View File

@@ -0,0 +1,41 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
def find_latest_roboplanet():
print("🔎 Searching for the latest Roboplanet (Group 52) Account...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# DIAGNOSTIC: Search for Account of Associate 528 (RCGO)
endpoint = "Contact?$filter=associateId eq 528&$orderby=contactId desc&$top=1&$select=contactId,name,Associate"
print(f"📡 Diagnostic Query: {endpoint}")
try:
results = client.search(endpoint)
if results and len(results) > 0:
contact = results[0]
print("\n✅ FOUND ACCOUNT FOR RCGO (528):")
print(json.dumps(contact, indent=2))
# Check GroupIdx
# Usually flat like "Associate": {"GroupIdx": 52...}
else:
print("\n❌ NO ACCOUNTS FOUND for RCGO (528).")
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
find_latest_roboplanet()

View File

@@ -0,0 +1,60 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
from config import settings
def find_missing():
print("🔎 Scanning for Associate IDs not in Whitelist...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
whitelist = settings.ROBOPLANET_WHITELIST
# Fetch 500 contacts
limit = 500
endpoint = f"Contact?$orderby=contactId desc&$top={limit}&$select=associateId"
print(f"📡 Scanning {limit} records...")
contacts = client.search(endpoint)
if contacts:
missing_ids = set()
match_count = 0
for c in contacts:
aid = c.get('associateId') or c.get('AssociateId')
if aid:
is_match = False
if str(aid).upper() in whitelist: is_match = True
try:
if int(aid) in whitelist: is_match = True
except: pass
if is_match:
match_count += 1
else:
missing_ids.add(aid)
print(f"\n📊 Scan Results ({limit} records):")
print(f" - Total Matches (Roboplanet): {match_count}")
print(f" - Missing/Other IDs: {len(missing_ids)}")
if missing_ids:
print("\n✅ Found IDs NOT in whitelist:")
for mid in sorted(list(missing_ids), key=lambda x: str(x)):
print(f" - {mid}")
print("\n👉 Bitte prüfe, ob eine dieser IDs ebenfalls zu Roboplanet gehört.")
else:
print("❌ No contacts found.")
if __name__ == "__main__":
find_missing()

View File

@@ -0,0 +1,54 @@
import os
import sys
import json
from dotenv import load_dotenv
# Explicitly load .env
dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
load_dotenv(dotenv_path=dotenv_path, override=True)
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from superoffice_client import SuperOfficeClient
def run_discovery():
print(f"🚀 Running Full Discovery on PRODUCTION...")
try:
client = SuperOfficeClient()
if not client.access_token: return
# 1. Check Contact UDFs
print("\n--- 🏢 CONTACT UDFs (ProgIDs) ---")
contact_meta = client._get("Contact/default")
if contact_meta and 'UserDefinedFields' in contact_meta:
udfs = contact_meta['UserDefinedFields']
for key in sorted(udfs.keys()):
print(f" - {key}")
# 2. Check Person UDFs
print("\n--- 👤 PERSON UDFs (ProgIDs) ---")
person_meta = client._get("Person/default")
if person_meta and 'UserDefinedFields' in person_meta:
udfs = person_meta['UserDefinedFields']
for key in sorted(udfs.keys()):
print(f" - {key}")
# 3. Check specific List IDs (e.g. Verticals)
# This often requires admin rights to see all list definitions
print("\n--- 📋 LIST CHECK (Verticals) ---")
# Assuming udlist331 is the list for Verticals (based on previous logs)
list_data = client._get("List/udlist331/Items")
if list_data and 'value' in list_data:
print(f"Found {len(list_data['value'])} items in Vertical list.")
for item in list_data['value'][:5]:
print(f" - ID {item['Id']}: {item['Name']}")
else:
print(" ⚠️ Could not access Vertical list items.")
print("\n✅ Discovery Complete.")
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
run_discovery()

View File

@@ -0,0 +1,84 @@
import sys
import os
import json
import traceback
from dotenv import load_dotenv
# Explicitly load .env from the project root
dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
load_dotenv(dotenv_path=dotenv_path, override=True)
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from superoffice_client import SuperOfficeClient
from config import settings
def get_enriched_data(contact_id: int):
print(f"🚀 [DEBUG] Starting fetch for ContactId: {contact_id}")
try:
client = SuperOfficeClient()
if not client.access_token:
print("❌ Authentication failed.")
return
print("✅ [DEBUG] Client authenticated.")
try:
contact_data = client.get_contact(
contact_id,
select=[
"Name", "UrlAddress", "OrgNr", "UserDefinedFields"
]
)
print(f"✅ [DEBUG] API Call successful. Data type: {type(contact_data)}")
except Exception as e:
print(f"❌ [DEBUG] API Call failed: {e}")
traceback.print_exc()
return
if not contact_data:
print("❌ [DEBUG] No data returned.")
return
print(f"✅ [DEBUG] Name: {contact_data.get('Name')}")
try:
udfs = contact_data.get("UserDefinedFields", {})
print(f"✅ [DEBUG] UDFs extracted. Type: {type(udfs)}")
except Exception as e:
print(f"❌ [DEBUG] Failed to extract UDFs: {e}")
traceback.print_exc()
return
if isinstance(udfs, dict):
print(f"✅ [DEBUG] UDFs is a dict with {len(udfs)} keys.")
try:
# Iterate keys safely
print("--- UDF KEYS SAMPLE ---")
for k in list(udfs.keys())[:5]:
print(f"Key: {k} (Type: {type(k)})")
except Exception as e:
print(f"❌ [DEBUG] Failed to iterate keys: {e}")
traceback.print_exc()
# Try to access specific key
target_key = settings.UDF_VERTICAL
print(f"✅ [DEBUG] Attempting access with key: '{target_key}' (Type: {type(target_key)})")
try:
if target_key in udfs:
val = udfs[target_key]
print(f"✅ [DEBUG] Value found: {val}")
else:
print(f" [DEBUG] Key not found in UDFs.")
except Exception as e:
print(f"❌ [DEBUG] Failed to access dictionary with key: {e}")
traceback.print_exc()
except Exception as e:
print(f"❌ [DEBUG] Global Error: {e}")
traceback.print_exc()
if __name__ == "__main__":
target_contact_id = 171185
get_enriched_data(target_contact_id)

View File

@@ -0,0 +1,98 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
def inspect_group():
print("🔎 Inspecting Group 52 (Roboplanet)...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# 1. Find Users in Group 52
print("\n👥 Finding Associates in Group 52...")
associates = client._get("MDOList/associate")
robo_associates = []
if associates:
for assoc in associates:
# Note: MDOList returns flat items.
# We might need to fetch details or check 'GroupIdx' if present in ExtraInfo
# Let's check keys first
# print(assoc.keys())
# The 'GroupIdx' is usually in 'ExtraInfo' or needs detail fetch
# But earlier discovery showed 'GroupIdx' directly? No, I inferred it.
# Let's fetch details for a few to be sure.
assoc_id = assoc.get('Id')
# Optimization: Only check first 50 to avoid spam, or check by Name if we know one
# Better: Use OData to filter associates by group?
# "Associate?$filter=groupIdx eq 52" -> Let's try this first!
pass
# Efficient OData Search for Associates in Group 52
users_in_group = client.search("Associate?$filter=groupIdx eq 52")
if users_in_group:
print(f"✅ Found {len(users_in_group)} Associates in Group 52:")
for u in users_in_group:
uid = u.get('associateId') or u.get('AssociateId')
name = u.get('name') or u.get('Name') or u.get('fullName')
print(f" - {name} (ID: {uid})")
robo_associates.append(uid)
else:
print("⚠️ No Associates found in Group 52 via OData.")
print(" Trying manual scan of MDOList (slower)...")
# Fallback loop
if associates:
count = 0
for assoc in associates:
aid = assoc.get('Id')
det = client._get(f"Associate/{aid}")
if det and det.get('GroupIdx') == 52:
print(f" - {det.get('Name')} (ID: {aid}) [via Detail]")
robo_associates.append(aid)
count += 1
if count > 5:
print(" ... (stopping scan)")
break
if not robo_associates:
print("❌ CRITICAL: Group 52 seems empty! Filter logic will block everything.")
return
# 2. Check a Contact owned by one of these users
test_user_id = robo_associates[0]
print(f"\n🏢 Checking a Contact owned by User {test_user_id}...")
contacts = client.search(f"Contact?$filter=associateId eq {test_user_id}&$top=1&$select=ContactId,Name,Associate/GroupIdx")
if contacts:
c = contacts[0]
cid = c.get('contactId') or c.get('ContactId')
cname = c.get('name') or c.get('Name')
# Check nested Associate GroupIdx if returned, or fetch detail
print(f" found: {cname} (ID: {cid})")
# Double Check with full Get
full_c = client.get_contact(cid)
assoc_grp = full_c.get('Associate', {}).get('GroupIdx')
print(f" 👉 Contact Associate GroupIdx: {assoc_grp}")
if assoc_grp == 52:
print("✅ VERIFIED: Filter logic 'GroupIdx == 52' will work.")
else:
print(f"❌ MISMATCH: Contact GroupIdx is {assoc_grp}, expected 52.")
else:
print("⚠️ User has no contacts. Cannot verify contact group mapping.")
if __name__ == "__main__":
inspect_group()

View File

@@ -0,0 +1,73 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
from config import settings
def run_precise_check():
print("📊 Precise Count Verification: API vs. Whitelist...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
whitelist = settings.ROBOPLANET_WHITELIST
ids_in_whitelist = [x for x in whitelist if isinstance(x, int)]
# 1. Individual Counts for our Whitelist IDs
print(f"\n🔢 Counting accounts for the {len(ids_in_whitelist)} IDs in whitelist...")
total_whitelist_count = 0
for aid in ids_in_whitelist:
endpoint = f"Contact?$filter=associateId eq {aid}&$top=0&$count=true"
try:
resp = client._get(endpoint)
count = resp.get('@odata.count') or 0
if count > 0:
# print(f" - ID {aid}: {count}")
total_whitelist_count += count
except:
pass
print(f"✅ Total accounts owned by Whitelist IDs: {total_whitelist_count}")
# 2. Check for "Strangers" in the Selection 10960
# We want to find who else is in that selection
print(f"\n🕵️ Looking for Owners in Selection 10960 who are NOT in our whitelist...")
# We use Archive/dynamic to group members by AssociateId
# This is the most efficient way to see all owners in the selection
endpoint = "Archive/dynamic?provider=selectionmember&columns=contact/associateId,contact/associate/name&criteria=selectionId=10960&$top=1000"
try:
members = client._get(endpoint)
if members and isinstance(members, list):
owners_in_selection = {}
for m in members:
aid = m.get("contact/associateId")
aname = m.get("contact/associate/name")
if aid:
owners_in_selection[aid] = aname
print(f"Found {len(owners_in_selection)} distinct owners in the first 1000 members of selection.")
for aid, name in owners_in_selection.items():
if aid not in whitelist and name not in whitelist:
print(f" ⚠️ OWNER NOT IN WHITELIST: {name} (ID: {aid})")
else:
print("⚠️ Could not group selection members by owner via API.")
except Exception as e:
print(f"⚠️ Archive grouping failed: {e}")
print(f"\n🏁 Target from UI: 17014")
print(f"🏁 Whitelist sum: {total_whitelist_count}")
delta = 17014 - total_whitelist_count
print(f"🏁 Delta: {delta}")
if __name__ == "__main__":
run_precise_check()

View File

@@ -0,0 +1,68 @@
import os
import json
import sys
from dotenv import load_dotenv
# Path gymnastics
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "connector-superoffice"))
from company_explorer_connector import get_company_details
from superoffice_client import SuperOfficeClient
# Load ENV
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
def perform_final_round_trip(ce_id):
client = SuperOfficeClient()
print(f"--- Final Round-Trip: CE {ce_id} -> SuperOffice ---")
# 1. Get enriched data from CE
ce_data = get_company_details(ce_id)
if not ce_data or "error" in ce_data:
print("❌ Could not fetch CE data.")
return
so_id = ce_data.get("crm_id")
if not so_id:
print("❌ No SO ID found in CE.")
return
# 2. Fetch current SO contact
contact = client._get(f"Contact/{so_id}")
if not contact:
print(f"❌ Could not fetch SO Contact {so_id}")
return
# 3. Intelligent Mapping (Full Object)
print(f"Mapping data for {ce_data.get('name')}...")
# Simple Fields
contact["UrlAddress"] = ce_data.get("website", "")
contact["Department"] = "KI-Enriched via CE"
# Address Object
if "Address" not in contact: contact["Address"] = {}
if "Street" not in contact["Address"]: contact["Address"]["Street"] = {}
contact["Address"]["Street"]["Address1"] = ce_data.get("address", "")
contact["Address"]["Street"]["City"] = ce_data.get("city", "")
contact["Address"]["Street"]["Zipcode"] = ce_data.get("zip", "")
# Phones (List)
if ce_data.get("phone"):
contact["Phones"] = [{"Number": ce_data.get("phone"), "Description": "Main"}]
# 4. Write back
print(f"Sending full update to SO Contact {so_id}...")
result = client._put(f"Contact/{so_id}", contact)
if result:
print("🚀 SUCCESS! Round-trip for Robo-Planet complete.")
print(f"Website: {contact['UrlAddress']}")
print(f"City: {contact['Address']['Street']['City']}")
else:
print("❌ Update failed.")
if __name__ == "__main__":
perform_final_round_trip(53)

View File

@@ -0,0 +1,111 @@
import sys
import os
import requests
import json
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
# Add paths to use backend models directly for complex seeding (Matrix/Person)
sys.path.append(os.path.join(os.getcwd(), "company-explorer"))
from backend.database import Base, Company, Contact, Industry, JobRoleMapping, MarketingMatrix
# Database Connection (Direct SQL access is easier for seeding specific IDs)
DB_PATH = "sqlite:///companies_v3_fixed_2.db" # Local relative path
engine = create_engine(DB_PATH)
Session = sessionmaker(bind=engine)
session = Session()
def seed():
print("--- Company Explorer Test Data Seeder ---")
print("This script prepares the database for the SuperOffice Connector End-to-End Test.")
# 1. User Input
so_contact_id = input("Enter SuperOffice Contact ID (Company) [e.g. 123]: ").strip()
so_person_id = input("Enter SuperOffice Person ID [e.g. 456]: ").strip()
company_name = input("Enter Company Name [e.g. Test GmbH]: ").strip() or "Test GmbH"
person_role = "Geschäftsführer" # Fixed for test simplicity
industry_name = "Logistik" # Fixed for test simplicity
if not so_contact_id or not so_person_id:
print("Error: IDs are required!")
return
print(f"\nSeeding for Company '{company_name}' (ID: {so_contact_id}) and Person (ID: {so_person_id})...")
# 2. Check/Create Industry
industry = session.query(Industry).filter_by(name=industry_name).first()
if not industry:
industry = Industry(name=industry_name, description="Test Industry")
session.add(industry)
session.commit()
print(f"✅ Created Industry '{industry_name}'")
else:
print(f" Industry '{industry_name}' exists")
# 3. Check/Create Job Role
role_map = session.query(JobRoleMapping).filter_by(role=person_role).first()
if not role_map:
role_map = JobRoleMapping(pattern=person_role, role=person_role) # Simple mapping
session.add(role_map)
session.commit()
print(f"✅ Created Role Mapping '{person_role}'")
else:
print(f" Role Mapping '{person_role}' exists")
# 4. Check/Create Company
company = session.query(Company).filter_by(crm_id=str(so_contact_id)).first()
if not company:
company = Company(
name=company_name,
crm_id=str(so_contact_id),
industry_ai=industry_name, # Link to our test industry
status="ENRICHED"
)
session.add(company)
session.commit()
print(f"✅ Created Company '{company_name}' with CRM-ID {so_contact_id}")
else:
company.industry_ai = industry_name # Ensure correct industry for test
session.commit()
print(f" Company '{company_name}' exists (Updated Industry)")
# 5. Check/Create Person
person = session.query(Contact).filter_by(so_person_id=int(so_person_id)).first()
if not person:
person = Contact(
company_id=company.id,
first_name="Max",
last_name="Mustermann",
so_person_id=int(so_person_id),
so_contact_id=int(so_contact_id),
role=person_role
)
session.add(person)
session.commit()
print(f"✅ Created Person with SO-ID {so_person_id}")
else:
person.role = person_role # Ensure role match
session.commit()
print(f" Person with SO-ID {so_person_id} exists (Updated Role)")
# 6. Check/Create Matrix Entry
matrix = session.query(MarketingMatrix).filter_by(industry_id=industry.id, role_id=role_map.id).first()
if not matrix:
matrix = MarketingMatrix(
industry_id=industry.id,
role_id=role_map.id,
subject="Test Betreff: Optimierung für {{company_name}}",
intro="Hallo, dies ist ein generierter Test-Text aus dem Company Explorer.",
social_proof="Wir arbeiten bereits erfolgreich mit anderen Logistikern zusammen."
)
session.add(matrix)
session.commit()
print(f"✅ Created Matrix Entry for {industry_name} x {person_role}")
else:
print(f" Matrix Entry exists")
print("\n🎉 Seeding Complete! The Company Explorer is ready.")
print(f"You can now trigger the Webhook for Contact {so_contact_id} / Person {so_person_id}.")
if __name__ == "__main__":
seed()

View File

@@ -0,0 +1,64 @@
import os
import json
import sys
import requests
from dotenv import load_dotenv
# Path gymnastics
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "connector-superoffice"))
from superoffice_client import SuperOfficeClient
from company_explorer_connector import get_company_details
# Load ENV
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
def final_correction(ce_id):
client = SuperOfficeClient()
print(f"--- Final Correction: Applying GROUND TRUTH for CE {ce_id} ---")
# 1. Force fetch from CE to ensure we have REAL data
ce_data = get_company_details(ce_id)
if not ce_data or "error" in ce_data:
# Fallback to manual ground truth from your message if API still flutters
ce_address = "Schatzbogen 39"
ce_city = "München"
ce_zip = "81829"
ce_name = "Robo-Planet GmbH"
else:
ce_address = ce_data.get("address", "Schatzbogen 39")
ce_city = ce_data.get("city", "München")
ce_zip = ce_data.get("zip", "81829")
ce_name = ce_data.get("name")
# 2. Map correctly
payload = {
"contactId": 2,
"Name": "RoboPlanet GmbH-SOD",
"Number2": "123",
"OrgNr": "DE343867623", # Real Robo-Planet VAT
"Address": {
"Postal": {
"Address1": ce_address,
"City": ce_city,
"Zipcode": ce_zip
},
"Street": {
"Address1": ce_address,
"City": ce_city,
"Zipcode": ce_zip
}
}
}
url = f"{client.base_url}/Contact/2"
resp = requests.put(url, headers=client.headers, json=payload)
if resp.status_code == 200:
print(f"🚀 SUCCESS! Applied Ground Truth: {ce_address}, {ce_city}")
else:
print(f"❌ Error: {resp.text}")
if __name__ == "__main__":
final_correction(53)

View File

@@ -0,0 +1,56 @@
import os
import json
import sys
import requests
from dotenv import load_dotenv
# Path gymnastics
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "connector-superoffice"))
from superoffice_client import SuperOfficeClient
# Load ENV
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
def force_write(so_id):
client = SuperOfficeClient()
print(f"--- Force Write-Back: Contact {so_id} ---")
# Using the mandatory fields you identified and the structured address
payload = {
"contactId": int(so_id),
"Name": "RoboPlanet GmbH-SOD",
"Number2": "123", # Mandatory field fix
"OrgNr": "DE348572190",
"Department": "Force Write 13:35",
"Address": {
"Postal": {
"Address1": "Humboldtstr. 1",
"City": "Dornstadt",
"Zipcode": "89160"
},
"Street": {
"Address1": "Humboldtstr. 1",
"City": "Dornstadt",
"Zipcode": "89160"
}
},
"UserDefinedFields": {
"SuperOffice:5": "[I:23]" # Vertical: Logistics
}
}
url = f"{client.base_url}/Contact/{so_id}"
print(f"Sending Force Payload to {url}...")
resp = requests.put(url, headers=client.headers, json=payload)
print(f"Status Code: {resp.status_code}")
if resp.status_code == 200:
print("🚀 SUCCESS! Check Address, VAT and Vertical now.")
else:
print(f"❌ Error: {resp.text}")
if __name__ == "__main__":
force_write(2)

View File

@@ -0,0 +1,62 @@
import os
import json
import sys
import requests
from dotenv import load_dotenv
# Path gymnastics
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "connector-superoffice"))
from superoffice_client import SuperOfficeClient
from company_explorer_connector import get_company_details
# Load ENV
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
def full_enrichment_writeback(ce_id):
client = SuperOfficeClient()
print(f"--- Full Enrichment Write-Back: CE {ce_id} -> SuperOffice ---")
# 1. Get data from CE
ce_data = get_company_details(ce_id)
if not ce_data or "error" in ce_data:
print("❌ Could not fetch CE data.")
return
so_id = ce_data.get("crm_id")
if not so_id:
print("❌ No SO ID found in CE.")
return
# 2. Build Surgical Payload (Postal Address, VAT, Vertical)
# We use the specific sub-object structure SO expects
payload = {
"contactId": int(so_id),
"OrgNr": "DE348572190", # Test VAT
"Department": "Fully Enriched 13:25",
"Address": {
"Postal": {
"Address1": ce_data.get("address", "Humboldtstr. 1"),
"City": ce_data.get("city", "Dornstadt"),
"Zipcode": ce_data.get("zip", "89160")
}
},
"UserDefinedFields": {
"SuperOffice:5": "[I:23]" # Vertical: Logistics - Warehouse
}
}
url = f"{client.base_url}/Contact/{so_id}"
print(f"Sending Full Payload to {url}...")
resp = requests.put(url, headers=client.headers, json=payload)
print(f"Status Code: {resp.status_code}")
if resp.status_code == 200:
print("🚀 SUCCESS! Full enrichment (Address, VAT, Vertical) should be visible.")
else:
print(f"❌ Error: {resp.text}")
if __name__ == "__main__":
full_enrichment_writeback(53)

View File

@@ -0,0 +1,66 @@
import os
import requests
import json
from dotenv import load_dotenv
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
def fix_all_now_v2():
# 1. Refresh Token
token_url = "https://sod.superoffice.com/login/common/oauth/tokens"
token_data = {
"grant_type": "refresh_token",
"client_id": os.getenv("SO_CLIENT_ID"),
"client_secret": os.getenv("SO_CLIENT_SECRET"),
"refresh_token": os.getenv("SO_REFRESH_TOKEN"),
"redirect_uri": "http://localhost"
}
t_resp = requests.post(token_url, data=token_data)
access_token = t_resp.json().get("access_token")
if not access_token:
print("❌ Token Refresh failed.")
return
# 2. Dual-Url Payload (Root + Array)
payload = {
"contactId": 2,
"Name": "RoboPlanet GmbH-SOD",
"Number2": "123",
"UrlAddress": "http://robo-planet.de",
"Urls": [
{
"Value": "http://robo-planet.de",
"Description": "Website"
}
],
"OrgNr": "DE400464410",
"Department": "Website Final Fix 13:42",
"Address": {
"Postal": {
"Address1": "Schatzbogen 39",
"City": "München",
"Zipcode": "81829"
}
},
"UserDefinedFields": {
"SuperOffice:5": "[I:23]"
}
}
# 3. Update Call
url = "https://app-sod.superoffice.com/Cust55774/api/v1/Contact/2"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
resp = requests.put(url, headers=headers, json=payload)
if resp.status_code == 200:
print("🚀 SUCCESS! Website should now be visible via the Urls list.")
else:
print(f"❌ Error: {resp.text}")
if __name__ == "__main__":
fix_all_now_v2()

View File

@@ -0,0 +1,57 @@
import os
import requests
from dotenv import load_dotenv
# Load ENV
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
def perfect_sync():
# Credentials
base_url = "https://app-sod.superoffice.com/Cust55774/api/v1"
headers = {
"Authorization": f"Bearer {os.getenv('SO_ACCESS_TOKEN')}", # Will be handled by client if needed, but here direct for speed
"Content-Type": "application/json",
"Accept": "application/json"
}
# We use the SuperOfficeClient to get a fresh token first
from repos.brancheneinstufung2.connector_superoffice.superoffice_client import SuperOfficeClient
client = SuperOfficeClient()
headers["Authorization"] = f"Bearer {client.access_token}"
print("--- Perfect Sync: Finalizing Robo-Planet (ID 2) ---")
payload = {
"contactId": 2,
"Name": "RoboPlanet GmbH-SOD",
"Number2": "123",
"UrlAddress": "http://robo-planet.de",
"OrgNr": "DE400464410",
"Department": "Perfectly Synchronized",
"Address": {
"Postal": {
"Address1": "Schatzbogen 39",
"City": "München",
"Zipcode": "81829"
},
"Street": {
"Address1": "Schatzbogen 39",
"City": "München",
"Zipcode": "81829"
}
},
"UserDefinedFields": {
"SuperOffice:5": "[I:23]" # Logistics
}
}
url = f"{base_url}/Contact/2"
resp = requests.put(url, headers=headers, json=payload)
if resp.status_code == 200:
print("🚀 BOOM. Website, VAT, Address and Vertical are now 100% correct.")
else:
print(f"❌ Error: {resp.text}")
if __name__ == "__main__":
perfect_sync()

View File

@@ -0,0 +1,38 @@
import os
import json
import sys
import requests
from dotenv import load_dotenv
# Path gymnastics
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "connector-superoffice"))
from superoffice_client import SuperOfficeClient
# Load ENV
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
def surgical_update(contact_id):
client = SuperOfficeClient()
print(f"--- Surgical Update: Contact {contact_id} ---")
# We use a MINIMAL payload. SuperOffice REST often prefers this for Stammfelder.
# Note: Using 'contactId' as it appeared in your discovery log.
payload = {
"contactId": int(contact_id),
"Department": "Surgical Update 13:20",
"UrlAddress": "http://robo-planet.de"
}
url = f"{client.base_url}/Contact/{contact_id}"
print(f"Sending PUT to {url} with payload: {payload}")
resp = requests.put(url, headers=client.headers, json=payload)
print(f"Status Code: {resp.status_code}")
print("Full Response Body:")
print(json.dumps(resp.json() if resp.content else {}, indent=2))
if __name__ == "__main__":
surgical_update(2)

View File

@@ -0,0 +1,44 @@
import os
import json
import sys
import requests
from dotenv import load_dotenv
# Path gymnastics
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "connector-superoffice"))
from superoffice_client import SuperOfficeClient
# Load ENV
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
def surgical_update_v2(contact_id):
client = SuperOfficeClient()
print(f"--- Surgical Update V2: Contact {contact_id} ---")
# We now use the proper 'Urls' list format
payload = {
"contactId": int(contact_id),
"Department": "Final Round-Trip 13:20",
"Urls": [
{
"Value": "http://robo-planet.de",
"Description": "Website"
}
]
}
url = f"{client.base_url}/Contact/{contact_id}"
print(f"Sending PUT to {url} with proper URL list...")
resp = requests.put(url, headers=client.headers, json=payload)
print(f"Status Code: {resp.status_code}")
if resp.status_code == 200:
print("✅ SUCCESS! Website should be visible now.")
else:
print(f"❌ Error: {resp.text}")
if __name__ == "__main__":
surgical_update_v2(2)

View File

@@ -0,0 +1,44 @@
import os
import json
import sys
from dotenv import load_dotenv
# Path gymnastics
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "connector-superoffice"))
from superoffice_client import SuperOfficeClient
# Load ENV
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
def debug_update(contact_id):
client = SuperOfficeClient()
print(f"--- Hard-Debug: Update Contact {contact_id} ---")
# 1. Fetch full existing object
contact = client._get(f"Contact/{contact_id}")
if not contact:
print("❌ Could not fetch contact.")
return
print(f"Current Name: {contact.get('Name')}")
print(f"Current Dept: {contact.get('Department')}")
# 2. Modify only one simple field
contact["Department"] = "AI-Test 13:10"
# 3. PUT it back
print("Sending full object back with modified Department...")
result = client._put(f"Contact/{contact_id}", contact)
if result:
print("✅ API accepted the update.")
# Verify immediately
updated = client._get(f"Contact/{contact_id}")
print(f"New Department in SO: {updated.get('Department')}")
else:
print("❌ Update failed.")
if __name__ == "__main__":
debug_update(2)

View File

@@ -0,0 +1,66 @@
import os
import json
import sys
from dotenv import load_dotenv
# Path gymnastics
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "connector-superoffice"))
from company_explorer_connector import get_company_details
from superoffice_client import SuperOfficeClient
# Load ENV
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
def round_trip_test(ce_id):
print(f"--- Starting Round-Trip POC: CE-ID {ce_id} -> SuperOffice ---")
# 1. Get enriched data from Company Explorer
ce_data = get_company_details(ce_id)
if not ce_data or "error" in ce_data:
print(f"❌ ERROR: Could not fetch data from Company Explorer for ID {ce_id}")
return
print(f"✅ Success: Received data from CE for '{ce_data.get('name')}'")
# 2. Extract CRM ID
so_id = ce_data.get("crm_id")
if not so_id:
print("❌ ERROR: No crm_id found in Company Explorer for this record. Cannot sync back.")
return
print(f"Targeting SuperOffice Contact ID: {so_id}")
# 3. Prepare SuperOffice Update Payload
# Based on your request: Address, Website, Email, Phone
# Note: We need to match the SO schema (Street Address vs Postal Address)
so_payload = {
"Name": ce_data.get("name"),
"UrlAddress": ce_data.get("website"),
"Address": {
"Street": {
"Address1": ce_data.get("address", ""), # Simplified mapping for POC
"City": ce_data.get("city", ""),
"Zipcode": ce_data.get("zip", "")
}
}
}
# 4. Perform Update via SuperOfficeClient
client = SuperOfficeClient()
print(f"Updating SuperOffice Contact {so_id}...")
# Using the generic PUT method from our client
endpoint = f"Contact/{so_id}"
result = client._put(endpoint, so_payload)
if result:
print(f"🚀 SUCCESS! Round-trip complete. SuperOffice Contact {so_id} updated.")
print(f"Updated Data: {ce_data.get('website')} | {ce_data.get('city')}")
else:
print("❌ ERROR: Failed to update SuperOffice.")
if __name__ == "__main__":
# Test with the ID you manually enriched
round_trip_test(53)

View File

@@ -0,0 +1,41 @@
import os
import json
import sys
from dotenv import load_dotenv
# Path gymnastics to ensure imports work from the current directory
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "connector-superoffice"))
from company_explorer_connector import handle_company_workflow
from superoffice_client import SuperOfficeClient
# Load ENV from correct path
load_dotenv(dotenv_path="/home/node/clawd/.env", override=True)
def sync_roboplanet():
print("--- Starting Sync Test: SuperOffice -> Company Explorer ---")
# 1. Fetch Contact from SuperOffice
client = SuperOfficeClient()
contact_id = 2
print(f"Fetching Contact ID {contact_id} from SuperOffice...")
contact_so = client._get(f"Contact/{contact_id}")
if not contact_so:
print("❌ ERROR: Could not find Contact ID 2 in SuperOffice.")
return
company_name = contact_so.get("Name")
print(f"✅ Success: Found '{company_name}' in SuperOffice.")
# 2. Push to Company Explorer
print(f"\nPushing '{company_name}' to Company Explorer via Connector...")
# Using the workflow to check existence and create if needed
result = handle_company_workflow(company_name)
print("\n--- WORKFLOW RESULT ---")
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
sync_roboplanet()

View File

@@ -0,0 +1,41 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
def test_membership(contact_id: int):
selection_id = 10960
print(f"🔎 Testing if Contact {contact_id} is member of Selection {selection_id}...")
client = SuperOfficeClient()
# Efficient Membership Check
# GET Selection/{id}/MemberStatus/Contact/{contactId}
endpoint = f"Selection/{selection_id}/MemberStatus/Contact/{contact_id}"
print(f"📡 Querying: {endpoint}")
try:
resp = client._get(endpoint)
print(f"✅ Response: {json.dumps(resp, indent=2)}")
# Result format is usually a string: "Member", "NotMember", "Excluded"
if resp == "Member":
print("🎯 YES: Contact is a member.")
else:
print("⏭️ NO: Contact is NOT a member.")
except Exception as e:
print(f"❌ Membership check failed: {e}")
if __name__ == "__main__":
# Test with Tanja Ullmann (171188) which we identified as Roboplanet
test_membership(171188)
# Test with Wackler parent (ID 3)
print("\n--- Control Test ---")
test_membership(3)

View File

@@ -0,0 +1,46 @@
import sys
import os
import requests
from dotenv import load_dotenv
# Explicitly load .env
dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
load_dotenv(dotenv_path=dotenv_path, override=True)
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from superoffice_client import SuperOfficeClient
def final_proof(contact_id: int):
print(f"🚀 Final Data Check for ContactId: {contact_id}")
try:
client = SuperOfficeClient()
# Get RAW text to be 100% safe
url = f"{client.base_url}/Contact/{contact_id}?$select=Name,UserDefinedFields"
resp = requests.get(url, headers=client.headers)
raw_text = resp.text
print("\n--- 🔍 EVIDENCE CHECK ---")
print(f"Company Name found: {'Bremer Abenteuerland' in raw_text}")
# Check for the Vertical ID '1628' (Leisure - Indoor Active)
if '"SuperOffice:83":"[I:1628]"' in raw_text:
print("✅ SUCCESS: Vertical 'Leisure - Indoor Active' (1628) is correctly set in SuperOffice!")
elif "1628" in raw_text:
print("⚠️ FOUND '1628' in response, but not in the expected field format.")
else:
print("❌ FAILURE: Vertical ID '1628' not found in SuperOffice response.")
# Check for Summary (truncated)
if "Abenteuerland" in raw_text and "SuperOffice:84" in raw_text:
print("✅ SUCCESS: AI Summary field (SuperOffice:84) seems to contain data.")
print("\n--- Summary of RAW Data (UDF part) ---")
# Just show a bit of the UDFs
start_idx = raw_text.find("UserDefinedFields")
print(raw_text[start_idx:start_idx+500] + "...")
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
final_proof(171185)

View File

@@ -0,0 +1,80 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
from config import settings
def find_latest_match():
print("🔎 Searching for the youngest account assigned to a Roboplanet user...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
whitelist = settings.ROBOPLANET_WHITELIST
print(f"📋 Whitelist contains {len(whitelist)} entries (IDs + Names).")
# 1. Fetch more contacts to find a match
limit = 1000
endpoint = f"Contact?$orderby=contactId desc&$top={limit}&$select=contactId,name,associateId"
print(f"📡 Fetching latest {limit} contacts (this may take a few seconds)...")
try:
contacts = client.search(endpoint)
if not contacts:
print("❌ No contacts returned from API.")
return
print(f"✅ Received {len(contacts)} contacts. Checking against whitelist...")
found = False
for i, c in enumerate(contacts):
if i > 0 and i % 100 == 0:
print(f" ... checked {i} records ...")
cid = c.get('contactId') or c.get('ContactId')
cname = c.get('name') or c.get('Name')
# Extract associate identifier (might be ID or Name)
raw_aid = c.get('associateId') or c.get('AssociateId')
is_match = False
if raw_aid:
# 1. Try as String (Name)
val_str = str(raw_aid).upper().strip()
if val_str in whitelist:
is_match = True
else:
# 2. Try as Int (ID)
try:
if int(raw_aid) in whitelist:
is_match = True
except (ValueError, TypeError):
pass
if is_match:
print("\n🎯 FOUND YOUNGEST ROBOPLANET ACCOUNT:")
print(f" - Company Name: {cname}")
print(f" - Contact ID: {cid}")
print(f" - Responsible Identifier: {raw_aid}")
print(f" - Link: https://online3.superoffice.com/Cust26720/default.aspx?contact?contact_id={cid}")
found = True
break
if not found:
print(f"\n⚠️ No match found in the last {limit} contacts.")
print(" This confirms that recent activity is from non-whitelist users.")
except Exception as e:
print(f"❌ Error: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
find_latest_match()

View File

@@ -0,0 +1,67 @@
import sys
import os
import json
# Absolute path setup
current_dir = os.path.dirname(os.path.abspath(__file__))
connector_dir = os.path.abspath(os.path.join(current_dir, '..'))
sys.path.insert(0, connector_dir)
from superoffice_client import SuperOfficeClient
from config import settings
def verify():
selection_id = 10960
print(f"🔎 Verifying members of Selection {selection_id}...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
# Use the Selection/ID/ContactMembers endpoint which is part of the REST API
# We ask for a few members and their Associate info
endpoint = f"Selection/{selection_id}/ContactMembers?$top=50&$select=ContactId,Name,AssociateId"
print(f"📡 Querying: {endpoint}")
try:
resp = client._get(endpoint)
# OData returns 'value'
members = resp.get('value', [])
if not members:
print("⚠️ No members found via REST. Trying alternative Archive call...")
# If REST fails, we might have to use a different approach
return
print(f"✅ Found {len(members)} members. Inspecting owners...")
whitelist = settings.ROBOPLANET_WHITELIST
owners_found = {}
for m in members:
cid = m.get('ContactId')
cname = m.get('Name')
# The AssociateId might be named differently in the response
aid = m.get('AssociateId')
if aid:
is_robo = aid in whitelist or str(aid).upper() in whitelist
status = "✅ ROBO" if is_robo else "❌ STRANGER"
owners_found[aid] = (status, aid)
# print(f" - Contact {cid} ({cname}): Owner {aid} [{status}]")
print("\n📊 Summary of Owners in Selection:")
for aid, (status, val) in owners_found.items():
print(f" {status}: Associate {aid}")
if any("STRANGER" in s for s, v in owners_found.values()):
print("\n⚠️ ALERT: Found owners in the selection who are NOT in our whitelist.")
print("This explains the delta. Please check if these IDs should be added.")
else:
print("\n✅ All sampled members belong to whitelist users.")
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
verify()

View File

@@ -0,0 +1,42 @@
import sys
import os
import json
from dotenv import load_dotenv
# Explicitly load .env from the project root
dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
load_dotenv(dotenv_path=dotenv_path, override=True)
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from superoffice_client import SuperOfficeClient
def get_current_user():
print(f"🚀 Fetching current user info via Associate/Me...")
try:
client = SuperOfficeClient()
if not client.access_token:
print("❌ Authentication failed.")
return
# Try the most reliable endpoint for current user context
user = client._get("Associate/Me")
if user:
print("\n--- 👤 Current User Info ---")
print(f"Associate ID: {user.get('AssociateId')}")
print(f"Name: {user.get('FullName')}")
print(f"UserName: {user.get('UserName')}")
print("----------------------------")
return user.get('AssociateId')
else:
# Fallback: List all associates and try to match by name or username
print("⚠️ Associate/Me failed. Trying alternative...")
# This might be too much data, but let's see
return None
except Exception as e:
print(f"❌ Error: {e}")
return None
if __name__ == "__main__":
get_current_user()

View File

@@ -0,0 +1,56 @@
from superoffice_client import SuperOfficeClient
import json
import logging
# Setup minimal logging
logging.basicConfig(level=logging.ERROR)
def verify_contact(contact_id):
print(f"🔍 Verifying REAL SuperOffice Data for Contact {contact_id}...")
client = SuperOfficeClient()
if not client.access_token:
print("❌ Auth failed.")
return
contact = client.get_contact(contact_id)
if not contact:
print("❌ Contact not found.")
return
# 1. Standard Fields
print("\n--- Standard Fields ---")
print(f"Name: {contact.get('Name')}")
print(f"OrgNr: {contact.get('OrgNr')}") # Changed from OrgNumber
addr = contact.get("Address", {}) # Changed from PostalAddress
print(f"Raw Address JSON: {json.dumps(addr, indent=2)}")
if addr:
postal = addr.get("Postal", {})
street = addr.get("Street", {})
print(f"Postal City: {postal.get('City')}")
print(f"Street City: {street.get('City')}")
else:
print("Address: (Empty)")
print("Address: (Empty)")
# 2. UDFs
print("\n--- User Defined Fields (UDFs) ---")
udfs = contact.get("UserDefinedFields", {})
if not udfs:
print("(No UDFs found)")
else:
for k, v in udfs.items():
# Filter relevant UDFs if possible, or show all
if "SuperOffice:" in k:
# Try to decode value if it's a list item like [I:26]
val_str = str(v)
print(f"{k}: {val_str}")
if __name__ == "__main__":
import sys
c_id = 6
if len(sys.argv) > 1:
c_id = int(sys.argv[1])
verify_contact(c_id)

View File

@@ -1,4 +1,5 @@
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from fastapi.responses import HTMLResponse
import logging
import os
import json
@@ -11,7 +12,7 @@ logger = logging.getLogger("connector-webhook")
app = FastAPI(title="SuperOffice Connector Webhook", version="2.0")
queue = JobQueue()
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "changeme")
WEBHOOK_TOKEN = os.getenv("WEBHOOK_TOKEN", "changeme")
@app.post("/webhook")
async def receive_webhook(request: Request, background_tasks: BackgroundTasks):
@@ -20,11 +21,10 @@ async def receive_webhook(request: Request, background_tasks: BackgroundTasks):
"""
# 1. Verify Secret (Basic Security)
# SuperOffice puts signature in headers, but for custom webhook we might just use query param or header
# Let's assume for now a shared secret in header 'X-SuperOffice-Signature' or similar
# Or simply a secret in the URL: /webhook?token=...
token = request.query_params.get("token")
if token != WEBHOOK_SECRET:
if token != WEBHOOK_TOKEN:
logger.warning(f"Invalid webhook token attempt: {token}")
raise HTTPException(403, "Invalid Token")
@@ -51,6 +51,228 @@ def health():
def stats():
return queue.get_stats()
@app.get("/api/jobs")
def get_jobs():
return queue.get_recent_jobs(limit=100)
@app.get("/api/accounts")
def get_accounts():
return queue.get_account_summary(limit=500)
@app.get("/dashboard", response_class=HTMLResponse)
def dashboard():
html_content = """
<!DOCTYPE html>
<html>
<head>
<title>Connector Dashboard</title>
<meta http-equiv="refresh" content="30">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 20px;
background: #0f172a;
color: #f1f5f9;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: #1e293b;
padding: 24px;
border-radius: 12px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
border: 1px solid #334155;
}
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
h1 { margin: 0; font-size: 24px; color: #f8fafc; }
.tabs { display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 1px solid #334155; padding-bottom: 10px; }
.tab { padding: 8px 16px; cursor: pointer; border-radius: 6px; font-weight: 500; font-size: 14px; color: #94a3b8; transition: all 0.2s; }
.tab:hover { background: #334155; color: #f8fafc; }
.tab.active { background: #3b82f6; color: white; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 14px; border-bottom: 1px solid #334155; font-size: 14px; }
th { background-color: #1e293b; color: #94a3b8; font-weight: 600; text-transform: uppercase; font-size: 12px; letter-spacing: 0.5px; }
tr:hover { background-color: #334155; }
.status { padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.status-PENDING { background: #334155; color: #cbd5e1; }
.status-PROCESSING { background: #1e40af; color: #bfdbfe; }
.status-COMPLETED { background: #064e3b; color: #a7f3d0; }
.status-FAILED { background: #7f1d1d; color: #fecaca; }
.status-SKIPPED { background: #475569; color: #cbd5e1; }
.status-DELETED { background: #7c2d12; color: #fdba74; }
.phases { display: flex; gap: 4px; align-items: center; }
.phase { width: 12px; height: 12px; border-radius: 50%; background: #334155; border: 2px solid #1e293b; box-shadow: 0 0 0 1px #334155; }
.phase.completed { background: #10b981; box-shadow: 0 0 0 1px #10b981; }
.phase.processing { background: #f59e0b; box-shadow: 0 0 0 1px #f59e0b; animation: pulse 1.5s infinite; }
.phase.failed { background: #ef4444; box-shadow: 0 0 0 1px #ef4444; }
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
.meta { color: #94a3b8; font-size: 12px; display: block; margin-top: 4px; }
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #cbd5e1;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 11px;
max-height: 80px;
overflow-y: auto;
background: #0f172a;
padding: 10px;
border-radius: 6px;
border: 1px solid #334155;
}
.hidden { display: none; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>🔌 SuperOffice Connector Dashboard</h1>
<div id="stats"></div>
</header>
<div class="tabs">
<div class="tab active" id="tab-accounts" onclick="switchTab('accounts')">Account View</div>
<div class="tab" id="tab-events" onclick="switchTab('events')">Event Log</div>
</div>
<div id="view-accounts">
<table>
<thead>
<tr>
<th>Account / Person</th>
<th width="100">Responsible</th>
<th width="120">ID</th>
<th width="150">Process Progress</th>
<th width="100">Duration</th>
<th width="120">Status</th>
<th width="150">Last Update</th>
<th>Details</th>
</tr>
</thead>
<tbody id="account-table">
<tr><td colspan="8" style="text-align:center;">Loading Accounts...</td></tr>
</tbody>
</table>
</div>
<div id="view-events" class="hidden">
<table>
<thead>
<tr>
<th width="50">ID</th>
<th width="120">Status</th>
<th width="150">Updated</th>
<th width="150">Event</th>
<th>Payload / Error</th>
</tr>
</thead>
<tbody id="event-table">
<tr><td colspan="5" style="text-align:center;">Loading Events...</td></tr>
</tbody>
</table>
</div>
</div>
<script>
let currentTab = 'accounts';
function switchTab(tab) {
currentTab = tab;
document.getElementById('tab-accounts').classList.toggle('active', tab === 'accounts');
document.getElementById('tab-events').classList.toggle('active', tab === 'events');
document.getElementById('view-accounts').classList.toggle('hidden', tab !== 'accounts');
document.getElementById('view-events').classList.toggle('hidden', tab !== 'events');
loadData();
}
async function loadData() {
if (currentTab === 'accounts') await loadAccounts();
else await loadEvents();
}
async function loadAccounts() {
try {
const response = await fetch('api/accounts');
const accounts = await response.json();
const tbody = document.getElementById('account-table');
tbody.innerHTML = '';
if (accounts.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;">No accounts in process</td></tr>';
return;
}
accounts.sort((a,b) => new Date(b.updated_at) - new Date(a.updated_at));
accounts.forEach(acc => {
const tr = document.createElement('tr');
const phasesHtml = `
<div class="phases">
<div class="phase ${acc.phases.received}" title="Received"></div>
<div class="phase ${acc.phases.enriching}" title="Enriching (CE)"></div>
<div class="phase ${acc.phases.syncing}" title="Syncing (SO)"></div>
<div class="phase ${acc.phases.completed}" title="Completed"></div>
</div>
`;
tr.innerHTML = `
<td>
<strong>${acc.name}</strong>
<span class="meta">${acc.last_event}</span>
</td>
<td><span class="status status-PENDING" style="font-size: 10px;">👤 ${acc.associate || '---'}</span></td>
<td>${acc.id}</td>
<td>${phasesHtml}</td>
<td><span class="meta">${acc.duration || '0s'}</span></td>
<td><span class="status status-${acc.status}">${acc.status}</span></td>
<td>${new Date(acc.updated_at + "Z").toLocaleTimeString()}</td>
<td><pre>${acc.error_msg || 'No issues'}</pre></td>
`;
tbody.appendChild(tr);
});
} catch (e) { console.error("Failed to load accounts", e); }
}
async function loadEvents() {
try {
const response = await fetch('api/jobs');
const jobs = await response.json();
const tbody = document.getElementById('event-table');
tbody.innerHTML = '';
jobs.forEach(job => {
const tr = document.createElement('tr');
let details = JSON.stringify(job.payload, null, 2);
if (job.error_msg) details += "\\n\\n🔴 ERROR: " + job.error_msg;
tr.innerHTML = `
<td>#${job.id}</td>
<td><span class="status status-${job.status}">${job.status}</span></td>
<td>${new Date(job.updated_at + "Z").toLocaleTimeString()}</td>
<td>${job.event_type}</td>
<td><pre>${details}</pre></td>
`;
tbody.appendChild(tr);
});
} catch (e) { console.error("Failed to load events", e); }
}
loadData();
setInterval(loadData, 5000);
</script>
</body>
</html>
"""
return HTMLResponse(content=html_content, status_code=200)
if __name__ == "__main__":
import uvicorn
uvicorn.run("webhook_app:app", host="0.0.0.0", port=8000, reload=True)

View File

@@ -3,8 +3,10 @@ import logging
import os
import requests
import json
from datetime import datetime
from queue_manager import JobQueue
from superoffice_client import SuperOfficeClient
from superoffice_client import SuperOfficeClient, ContactNotFoundException
from config import settings
# Setup Logging
logging.basicConfig(
@@ -13,268 +15,335 @@ logging.basicConfig(
)
logger = logging.getLogger("connector-worker")
# Config
COMPANY_EXPLORER_URL = os.getenv("COMPANY_EXPLORER_URL", "http://company-explorer:8000")
# Poll Interval
POLL_INTERVAL = 5 # Seconds
# UDF Mapping (DEV) - Should be moved to config later
UDF_MAPPING = {
"subject": "SuperOffice:5",
"intro": "SuperOffice:6",
"social_proof": "SuperOffice:7"
}
def process_job(job, so_client: SuperOfficeClient):
def safe_get_udfs(entity_data):
"""
Core logic for processing a single job.
Safely retrieves UserDefinedFields from an entity dictionary.
Handles the 'TypeError: unhashable type: dict' bug in SuperOffice Prod API.
"""
logger.info(f"Processing Job {job['id']} ({job['event_type']})")
payload = job['payload']
event_low = job['event_type'].lower()
# 1. Extract IDs from Webhook Payload
person_id = None
contact_id = None
if "PersonId" in payload:
person_id = int(payload["PersonId"])
elif "PrimaryKey" in payload and "person" in event_low:
person_id = int(payload["PrimaryKey"])
if "ContactId" in payload:
contact_id = int(payload["ContactId"])
elif "PrimaryKey" in payload and "contact" in event_low:
contact_id = int(payload["PrimaryKey"])
# Fallback/Deep Lookup
if not contact_id and person_id:
person_data = so_client.get_person(person_id)
if person_data and "Contact" in person_data:
contact_id = person_data["Contact"].get("ContactId")
if not contact_id:
raise ValueError(f"Could not identify ContactId in payload: {payload}")
logger.info(f"Target: Person {person_id}, Contact {contact_id}")
# --- Cascading Logic ---
# If a company changes, we want to update all its persons eventually.
# We do this by adding "person.changed" jobs for each person to the queue.
if "contact" in event_low and not person_id:
logger.info(f"Company event detected. Triggering cascade for all persons of Contact {contact_id}.")
try:
persons = so_client.search(f"Person?$filter=contact/contactId eq {contact_id}")
if persons:
q = JobQueue()
for p in persons:
p_id = p.get("PersonId")
if p_id:
logger.info(f"Cascading: Enqueueing job for Person {p_id}")
q.add_job("person.changed", {"PersonId": p_id, "ContactId": contact_id, "Source": "Cascade"})
except Exception as e:
logger.warning(f"Failed to cascade to persons for contact {contact_id}: {e}")
# 1b. Fetch full contact details for 'Double Truth' check (Master Data Sync)
crm_name = None
crm_website = None
if not entity_data: return {}
try:
contact_details = so_client.get_contact(contact_id)
if contact_details:
crm_name = contact_details.get("Name")
crm_website = contact_details.get("UrlAddress")
if not crm_website and "Urls" in contact_details and contact_details["Urls"]:
crm_website = contact_details["Urls"][0].get("Value")
return entity_data.get("UserDefinedFields", {})
except TypeError:
logger.warning("⚠️ API BUG: UserDefinedFields structure is corrupted (unhashable dict). Treating as empty.")
return {}
except Exception as e:
logger.warning(f"Failed to fetch contact details for {contact_id}: {e}")
logger.error(f"Error reading UDFs: {e}")
return {}
# 2. Call Company Explorer Provisioning API
ce_url = f"{COMPANY_EXPLORER_URL}/api/provision/superoffice-contact"
ce_req = {
"so_contact_id": contact_id,
"so_person_id": person_id,
"job_title": payload.get("JobTitle"),
"crm_name": crm_name,
"crm_website": crm_website
}
ce_auth = (os.getenv("API_USER", "admin"), os.getenv("API_PASSWORD", "gemini"))
try:
resp = requests.post(ce_url, json=ce_req, auth=ce_auth)
if resp.status_code == 404:
logger.warning(f"Company Explorer returned 404. Retrying later.")
return "RETRY"
resp.raise_for_status()
provisioning_data = resp.json()
if provisioning_data.get("status") == "processing":
logger.info(f"Company Explorer is processing {provisioning_data.get('company_name', 'Unknown')}. Re-queueing job.")
return "RETRY"
if provisioning_data.get("status") == "processing":
logger.info(f"Company Explorer is processing {provisioning_data.get('company_name', 'Unknown')}. Re-queueing job.")
return "RETRY"
except requests.exceptions.RequestException as e:
raise Exception(f"Company Explorer API failed: {e}")
logger.info(f"CE Response for Contact {contact_id}: {json.dumps(provisioning_data)}") # DEBUG
# 2b. Sync Vertical to SuperOffice (Company Level)
vertical_name = provisioning_data.get("vertical_name")
if vertical_name:
# Mappings from README
VERTICAL_MAP = {
"Logistics - Warehouse": 23,
"Healthcare - Hospital": 24,
"Infrastructure - Transport": 25,
"Leisure - Indoor Active": 26
}
vertical_id = VERTICAL_MAP.get(vertical_name)
if vertical_id:
logger.info(f"Identified Vertical '{vertical_name}' -> ID {vertical_id}")
try:
# Check current value to avoid loops
current_contact = so_client.get_contact(contact_id)
current_udfs = current_contact.get("UserDefinedFields", {})
current_val = current_udfs.get("SuperOffice:5", "")
# Normalize SO list ID format (e.g., "[I:26]" -> "26")
if current_val and current_val.startswith("[I:"):
current_val = current_val.split(":")[1].strip("]")
if str(current_val) != str(vertical_id):
logger.info(f"Updating Contact {contact_id} Vertical: {current_val} -> {vertical_id}")
so_client.update_entity_udfs(contact_id, "Contact", {"SuperOffice:5": str(vertical_id)})
else:
logger.info(f"Vertical for Contact {contact_id} already in sync ({vertical_id}).")
except Exception as e:
logger.error(f"Failed to sync vertical for Contact {contact_id}: {e}")
else:
logger.warning(f"Vertical '{vertical_name}' not found in internal mapping.")
# 2c. Sync Website (Company Level)
# TEMPORARILY DISABLED TO PREVENT LOOP (SO API Read-after-Write latency or field mapping issue)
"""
website = provisioning_data.get("website")
if website and website != "k.A.":
try:
# Re-fetch contact to ensure we work on latest version (Optimistic Concurrency)
contact_data = so_client.get_contact(contact_id)
current_url = contact_data.get("UrlAddress", "")
# Normalize for comparison
def norm(u): return str(u).lower().replace("https://", "").replace("http://", "").strip("/") if u else ""
if norm(current_url) != norm(website):
logger.info(f"Updating Website for Contact {contact_id}: {current_url} -> {website}")
# Update Urls collection (Rank 1)
new_urls = []
if "Urls" in contact_data:
found = False
for u in contact_data["Urls"]:
if u.get("Rank") == 1:
u["Value"] = website
found = True
new_urls.append(u)
if not found:
new_urls.append({"Value": website, "Rank": 1, "Description": "Website"})
contact_data["Urls"] = new_urls
else:
contact_data["Urls"] = [{"Value": website, "Rank": 1, "Description": "Website"}]
# Also set main field if empty
if not current_url:
contact_data["UrlAddress"] = website
# Write back full object
so_client._put(f"Contact/{contact_id}", contact_data)
else:
logger.info(f"Website for Contact {contact_id} already in sync.")
except Exception as e:
logger.error(f"Failed to sync website for Contact {contact_id}: {e}")
"""
# 3. Update SuperOffice (Only if person_id is present)
if not person_id:
logger.info("Sync complete (Company only). No texts to write back.")
return "SUCCESS"
texts = provisioning_data.get("texts", {})
if not any(texts.values()):
logger.info("No texts returned from Matrix (yet). Skipping write-back.")
return "SUCCESS"
udf_update = {}
if texts.get("subject"): udf_update[UDF_MAPPING["subject"]] = texts["subject"]
if texts.get("intro"): udf_update[UDF_MAPPING["intro"]] = texts["intro"]
if texts.get("social_proof"): udf_update[UDF_MAPPING["social_proof"]] = texts["social_proof"]
if udf_update:
# Loop Prevention
try:
current_person = so_client.get_person(person_id)
current_udfs = current_person.get("UserDefinedFields", {})
needs_update = False
for key, new_val in udf_update.items():
if current_udfs.get(key, "") != new_val:
needs_update = True
break
if needs_update:
logger.info(f"Applying update to Person {person_id} (Changes detected).")
success = so_client.update_entity_udfs(person_id, "Person", udf_update)
if not success:
raise Exception("Failed to update SuperOffice UDFs")
else:
logger.info(f"Skipping update for Person {person_id}: Values match (Loop Prevention).")
except Exception as e:
logger.error(f"Error during pre-update check: {e}")
raise
logger.info("Job successfully processed.")
return "SUCCESS"
def clean_text_for_so(text, limit=200):
"""Clean and truncate text for SuperOffice UDF compatibility."""
if not text or text == "null": return ""
# Strip whitespace and truncate to safe limit
return str(text).strip()[:limit]
def run_worker():
queue = JobQueue()
# Initialize SO Client with retry
so_client = None
self_associate_id = None
while not so_client:
try:
so_client = SuperOfficeClient()
if not so_client.access_token: raise Exception("Auth failed")
# Dynamic ID Fetch for Echo Prevention
try:
me = so_client._get("Associate/Me")
if me:
self_associate_id = me.get("AssociateId")
logger.info(f"✅ Worker Identity Confirmed: Associate ID {self_associate_id}")
except Exception as e:
logger.warning(f"Could not fetch own Associate ID: {e}. Echo prevention might be limited to ID 528.")
self_associate_id = 528 # Fallback
except Exception as e:
logger.critical(f"Failed to initialize SuperOffice Client: {e}. Retrying in 30s...")
logger.critical(f"Failed to initialize SO Client. Retrying in 30s...")
time.sleep(30)
logger.info("Worker started. Polling queue...")
while True:
try:
job = queue.get_next_job()
if job:
try:
result = process_job(job, so_client)
if result == "RETRY":
queue.retry_job_later(job['id'], delay_seconds=120)
else:
# Pass self_id to process_job
job['self_associate_id'] = self_associate_id
status, msg = process_job(job, so_client, queue)
if status == "RETRY":
queue.retry_job_later(job['id'], delay_seconds=120, error_msg=msg)
elif status == "FAILED":
queue.fail_job(job['id'], msg or "Job failed status")
elif status == "SKIPPED":
queue.skip_job(job['id'], msg or "Skipped")
elif status == "DELETED":
queue.mark_as_deleted(job['id'], msg or "Deleted in SuperOffice")
else:
queue.complete_job(job['id'])
except Exception as e:
logger.error(f"Job {job['id']} failed: {e}", exc_info=True)
queue.fail_job(job['id'], str(e))
else:
time.sleep(POLL_INTERVAL)
except Exception as e:
logger.error(f"Worker loop error: {e}")
time.sleep(POLL_INTERVAL)
def process_job(job, so_client: SuperOfficeClient, queue: JobQueue):
"""
Core logic for processing a single job.
Returns: (STATUS, MESSAGE)
STATUS: 'SUCCESS', 'SKIPPED', 'DELETED', 'RETRY', 'FAILED'
"""
logger.info(f"--- [WORKER v2.1.1 - FULL LOGIC RESTORED] Processing Job {job['id']} ({job['event_type']}) ---")
payload = job['payload']
event_low = job['event_type'].lower()
# --- 1. HARD ECHO SHIELD (Who triggered this?) ---
changed_by = payload.get("ChangedByAssociateId")
self_id = job.get('self_associate_id')
if changed_by and self_id and int(changed_by) == int(self_id):
msg = f"🛡️ ECHO DETECTED: Event triggered by myself (ID {self_id}). Stopping immediately."
logger.info(msg)
return ("SKIPPED", msg)
# --- 2. NOISE REDUCTION: FIELD FILTER (What changed?) ---
changes = [c.lower() for c in payload.get("Changes", [])]
if "person" in event_low:
# We allow contact_id changes (linking to company) and basic identity changes
if "name" not in changes and "email" not in changes and "jobtitle" not in changes and "contact_id" not in changes:
msg = f"Skipping person event: No relevant changes (Name/Email/JobTitle/Mapping) in {changes}."
logger.info(f"⏭️ {msg}")
return ("SKIPPED", msg)
elif "contact" in event_low:
if "name" not in changes and "urladdress" not in changes:
msg = f"Skipping contact event: No relevant changes (Name/Website) in {changes}."
logger.info(f"⏭️ {msg}")
return ("SKIPPED", msg)
# 0. ID Extraction & Early Exit for irrelevant jobs
person_id = None
contact_id = None
job_title = payload.get("JobTitle")
field_values = payload.get("FieldValues", {})
if "person_id" in field_values:
person_id = int(field_values["person_id"])
if "contact_id" in field_values:
contact_id = int(field_values["contact_id"])
if "title" in field_values and not job_title:
job_title = field_values["title"]
if not person_id:
if "PersonId" in payload:
person_id = int(payload["PersonId"])
elif "PrimaryKey" in payload and "person" in event_low:
person_id = int(payload["PrimaryKey"])
if not contact_id:
if "ContactId" in payload:
contact_id = int(payload["ContactId"])
elif "PrimaryKey" in payload and "contact" in event_low:
contact_id = int(payload["PrimaryKey"])
if not person_id and not contact_id:
msg = f"Skipping job: No ContactId or PersonId identified."
logger.warning(msg)
return ("SKIPPED", msg)
# Fallback Lookup
if person_id and (not job_title or not contact_id):
try:
person_details = so_client.get_person(person_id, select=["JobTitle", "Title", "Contact/ContactId"])
if person_details:
if not job_title: job_title = person_details.get("JobTitle") or person_details.get("Title")
if not contact_id:
contact_obj = person_details.get("Contact")
if contact_obj and isinstance(contact_obj, dict): contact_id = contact_obj.get("ContactId")
except ContactNotFoundException:
return ("DELETED", f"Person {person_id} not found.")
except Exception as e:
logger.warning(f"Failed to fetch person details for {person_id}: {e}")
if any(x in event_low for x in ["sale.", "project.", "appointment.", "document.", "selection."]):
return ("SKIPPED", f"Irrelevant event type: {job['event_type']}")
if not contact_id: raise ValueError(f"No ContactId found.")
logger.info(f"Target Identified -> Person: {person_id}, Contact: {contact_id}, JobTitle: {job_title}")
# 1b. Fetch full contact details
crm_name, crm_website, crm_industry_name, contact_details, campaign_tag = None, None, None, None, None
try:
contact_details = so_client.get_contact(contact_id, select=["Name", "UrlAddress", "Urls", "UserDefinedFields", "Address", "OrgNr", "Associate"])
crm_name = contact_details.get("Name", "Unknown")
assoc = contact_details.get("Associate") or {}
aname = assoc.get("Name", "").upper().strip()
queue.update_entity_name(job['id'], crm_name, associate_name=aname)
# ROBOPLANET FILTER
is_robo = False
if aname in settings.ROBOPLANET_WHITELIST:
is_robo = True
else:
try:
aid = assoc.get("AssociateId")
if aid and int(aid) in settings.ROBOPLANET_WHITELIST:
is_robo = True
except (ValueError, TypeError): pass
if not is_robo:
msg = f"Skipped, Wackler. Contact {contact_id} ('{crm_name}'): Owner '{aname}' is not in Roboplanet whitelist."
logger.info(f"⏭️ {msg}")
return ("SKIPPED", msg)
crm_website = contact_details.get("UrlAddress")
# Campaign Tag
if person_id:
try:
person_details = so_client.get_person(person_id, select=["UserDefinedFields"])
if person_details and settings.UDF_CAMPAIGN:
udfs = safe_get_udfs(person_details)
campaign_tag = udfs.get(f"{settings.UDF_CAMPAIGN}:DisplayText") or udfs.get(settings.UDF_CAMPAIGN)
except Exception: pass
# Current Vertical
if settings.UDF_VERTICAL:
udfs = safe_get_udfs(contact_details)
so_vertical_val = udfs.get(settings.UDF_VERTICAL)
if so_vertical_val:
val_str = str(so_vertical_val).replace("[I:" ,"").replace("]","")
try:
v_map = json.loads(settings.VERTICAL_MAP_JSON)
crm_industry_name = {str(v): k for k, v in v_map.items()}.get(val_str)
except Exception: pass
except ContactNotFoundException:
return ("DELETED", f"Contact {contact_id} not found.")
except Exception as e:
raise Exception(f"SuperOffice API Failure: {e}")
# --- 3. Company Explorer Provisioning ---
ce_url = f"{settings.COMPANY_EXPLORER_URL}/api/provision/superoffice-contact"
ce_req = {"so_contact_id": contact_id, "so_person_id": person_id, "job_title": job_title, "crm_name": crm_name, "crm_website": crm_website, "crm_industry_name": crm_industry_name, "campaign_tag": campaign_tag}
try:
auth_tuple = (os.getenv("API_USER", "admin"), os.getenv("API_PASSWORD", "gemini"))
resp = requests.post(ce_url, json=ce_req, auth=auth_tuple)
if resp.status_code == 404: return ("RETRY", "CE 404")
resp.raise_for_status()
provisioning_data = resp.json()
if provisioning_data.get("status") == "processing": return ("RETRY", "CE processing")
except Exception as e:
raise Exception(f"Company Explorer API failed: {e}")
# Fetch fresh Contact for comparison
contact_data = so_client.get_contact(contact_id)
if not contact_data: return ("SKIPPED", "Contact deleted post-analysis")
current_udfs = safe_get_udfs(contact_data)
contact_patch = {}
# --- A. Vertical Sync ---
vertical_name = provisioning_data.get("vertical_name")
if vertical_name:
v_map = json.loads(settings.VERTICAL_MAP_JSON)
vertical_id = v_map.get(vertical_name)
if vertical_id:
current_val = str(current_udfs.get(settings.UDF_VERTICAL, "")).replace("[I:" ,"").replace("]","")
if current_val != str(vertical_id):
contact_patch.setdefault("UserDefinedFields", {})[settings.UDF_VERTICAL] = str(vertical_id)
# --- B. Address & VAT Sync ---
ce_city, ce_street, ce_zip, ce_vat = provisioning_data.get("address_city"), provisioning_data.get("address_street"), provisioning_data.get("address_zip"), provisioning_data.get("vat_id")
if ce_city or ce_street or ce_zip:
for type_key in ["Postal", "Street"]:
cur_addr = (contact_data.get("Address") or {}).get(type_key, {})
if ce_city and cur_addr.get("City") != ce_city: contact_patch.setdefault("Address", {}).setdefault(type_key, {})["City"] = ce_city
if ce_street and cur_addr.get("Address1") != ce_street: contact_patch.setdefault("Address", {}).setdefault(type_key, {})["Address1"] = ce_street
if ce_zip and cur_addr.get("Zipcode") != ce_zip: contact_patch.setdefault("Address", {}).setdefault(type_key, {})["Zipcode"] = ce_zip
if ce_vat and contact_data.get("OrgNr") != ce_vat:
contact_patch["OrgNr"] = ce_vat
# --- C. AI Openers & Summary Sync ---
p_opener = clean_text_for_so(provisioning_data.get("opener"), 200)
s_opener = clean_text_for_so(provisioning_data.get("opener_secondary"), 200)
ai_sum = clean_text_for_so(provisioning_data.get("summary"), 132)
if p_opener and current_udfs.get(settings.UDF_OPENER) != p_opener:
contact_patch.setdefault("UserDefinedFields", {})[settings.UDF_OPENER] = p_opener
if s_opener and current_udfs.get(settings.UDF_OPENER_SECONDARY) != s_opener:
contact_patch.setdefault("UserDefinedFields", {})[settings.UDF_OPENER_SECONDARY] = s_opener
if ai_sum and current_udfs.get(settings.UDF_SUMMARY) != ai_sum:
contact_patch.setdefault("UserDefinedFields", {})[settings.UDF_SUMMARY] = ai_sum
# --- D. Timestamps ---
if settings.UDF_LAST_UPDATE and contact_patch:
now_so = f"[D:{datetime.now().strftime('%m/%d/%Y %H:%M:%S')}]"
contact_patch.setdefault("UserDefinedFields", {})[settings.UDF_LAST_UPDATE] = now_so
if ce_website := provisioning_data.get("website"):
current_urls = contact_data.get("Urls") or []
if not any(u.get("Value") == ce_website for u in current_urls):
contact_patch.setdefault("Urls", []).append({"Value": ce_website, "Description": "AI Discovered"})
if contact_patch:
logger.info(f"🚀 Pushing combined PATCH for Contact {contact_id}: {list(contact_patch.get('UserDefinedFields', {}).keys())}")
so_client.patch_contact(contact_id, contact_patch)
else:
logger.info(f"✅ No changes detected for Contact {contact_id}.")
# 2d. Sync Person Position
role_name = provisioning_data.get("role_name")
if person_id and role_name:
try:
persona_map = json.loads(settings.PERSONA_MAP_JSON)
position_id = persona_map.get(role_name)
if position_id:
so_client.update_person_position(person_id, int(position_id))
except Exception as e:
logger.error(f"Error syncing position: {e}")
# 3. Update SuperOffice Texts (Person)
if person_id:
texts = provisioning_data.get("texts", {})
unsubscribe_link = provisioning_data.get("unsubscribe_link")
udf_update = {}
if texts.get("subject"): udf_update[settings.UDF_SUBJECT] = texts["subject"]
if texts.get("intro"): udf_update[settings.UDF_INTRO] = texts["intro"]
if texts.get("social_proof"): udf_update[settings.UDF_SOCIAL_PROOF] = texts["social_proof"]
if unsubscribe_link and settings.UDF_UNSUBSCRIBE_LINK:
udf_update[settings.UDF_UNSUBSCRIBE_LINK] = unsubscribe_link
if udf_update:
logger.info(f"Applying text update to Person {person_id}.")
so_client.update_entity_udfs(person_id, "Person", udf_update)
# --- 4. Create Email Simulation Appointment ---
try:
opener = provisioning_data.get("opener") or ""
intro = texts.get("intro") or ""
proof = texts.get("social_proof") or ""
subject = texts.get("subject", "No Subject")
email_body = f"Betreff: {subject}\n\n{opener}\n\n{intro}\n\n{proof}\n\n(Generated via Gemini Marketing Engine)"
so_client.create_appointment(f"KI: {subject}", email_body, contact_id, person_id)
except Exception as e:
logger.error(f"Failed simulation: {e}")
return ("SUCCESS", "Processing complete")
if __name__ == "__main__":
run_worker()
run_worker()