# EspoCRM API Service

## Overview

Python client for EspoCRM REST API integration. Provides type-safe, async operations for managing entities in EspoCRM.

## Features

- ✅ API Key authentication
- ✅ Async/await support (aiohttp)
- ✅ Full CRUD operations
- ✅ Entity search and filtering
- ✅ Error handling with custom exceptions
- ✅ Optional Redis integration for caching
- ✅ Logging via Motia context

## Installation

```python
from services.espocrm import EspoCRMAPI

# Initialize with optional context for logging
espo = EspoCRMAPI(context=context)
```

## Configuration

Add to `.env` or environment:

```bash
# EspoCRM API Configuration
ESPOCRM_API_BASE_URL=https://crm.bitbylaw.com/api/v1
ESPOCRM_MARVIN_API_KEY=your_api_key_here
ESPOCRM_API_TIMEOUT_SECONDS=30
```

Required in `config.py`:

```python
class Config:
    ESPOCRM_API_BASE_URL = os.getenv('ESPOCRM_API_BASE_URL', 'https://crm.bitbylaw.com/api/v1')
    ESPOCRM_API_KEY = os.getenv('ESPOCRM_MARVIN_API_KEY', '')
    ESPOCRM_API_TIMEOUT_SECONDS = int(os.getenv('ESPOCRM_API_TIMEOUT_SECONDS', '30'))
```

## API Methods

### Get Single Entity

```python
async def get_entity(entity_type: str, entity_id: str) -> Dict[str, Any]
```

**Usage:**
```python
# Get Beteiligter by ID
result = await espo.get_entity('Beteiligte', '64a3f2b8c9e1234567890abc')
print(result['name'])
```

### List Entities

```python
async def list_entities(
    entity_type: str,
    where: Optional[List[Dict]] = None,
    select: Optional[str] = None,
    order_by: Optional[str] = None,
    offset: int = 0,
    max_size: int = 50
) -> Dict[str, Any]
```

**Usage:**
```python
# List all Beteiligte with status "Active"
result = await espo.list_entities(
    'Beteiligte',
    where=[{
        'type': 'equals',
        'attribute': 'status',
        'value': 'Active'
    }],
    select='id,name,email',
    max_size=100
)

for entity in result['list']:
    print(entity['name'])
print(f"Total: {result['total']}")
```

**Complex Filters:**
```python
# OR condition
where=[{
    'type': 'or',
    'value': [
        {'type': 'equals', 'attribute': 'status', 'value': 'Zurückgestellt'},
        {'type': 'equals', 'attribute': 'status', 'value': 'Warte auf neuen Anruf'}
    ]
}]

# AND condition
where=[
    {'type': 'equals', 'attribute': 'status', 'value': 'Active'},
    {'type': 'greaterThan', 'attribute': 'createdAt', 'value': '2026-01-01'}
]
```

### Create Entity

```python
async def create_entity(entity_type: str, data: Dict[str, Any]) -> Dict[str, Any]
```

**Usage:**
```python
# Create new Beteiligter
result = await espo.create_entity('Beteiligte', {
    'name': 'Max Mustermann',
    'email': 'max@example.com',
    'phone': '+49123456789',
    'status': 'New'
})
print(f"Created with ID: {result['id']}")
```

### Update Entity

```python
async def update_entity(
    entity_type: str,
    entity_id: str,
    data: Dict[str, Any]
) -> Dict[str, Any]
```

**Usage:**
```python
# Update Beteiligter status
result = await espo.update_entity(
    'Beteiligte',
    '64a3f2b8c9e1234567890abc',
    {'status': 'Converted'}
)
```

### Delete Entity

```python
async def delete_entity(entity_type: str, entity_id: str) -> bool
```

**Usage:**
```python
# Delete Beteiligter
success = await espo.delete_entity('Beteiligte', '64a3f2b8c9e1234567890abc')
```

### Search Entities

```python
async def search_entities(
    entity_type: str,
    query: str,
    fields: Optional[List[str]] = None
) -> List[Dict[str, Any]]
```

**Usage:**
```python
# Full-text search
results = await espo.search_entities('Beteiligte', 'Mustermann')
for entity in results:
    print(entity['name'])
```

## Common Entity Types

Based on EspoCRM standard and VMH customization:

- `Beteiligte` - Custom entity for VMH participants
- `CVmhErstgespraech` - Custom entity for VMH initial consultations
- `Contact` - Standard contacts
- `Account` - Companies/Organizations
- `Lead` - Sales leads
- `Opportunity` - Sales opportunities
- `Case` - Support cases
- `Meeting` - Calendar meetings
- `Call` - Phone calls
- `Email` - Email records

## Error Handling

```python
from services.espocrm import EspoCRMError, EspoCRMAuthError

try:
    result = await espo.get_entity('Beteiligte', entity_id)
except EspoCRMAuthError as e:
    # Invalid API key
    context.logger.error(f"Authentication failed: {e}")
except EspoCRMError as e:
    # General API error (404, 403, etc.)
    context.logger.error(f"API error: {e}")
```

## Authentication

EspoCRM uses **API Key authentication** via `X-Api-Key` header.

**Create API Key in EspoCRM:**
1. Login as admin
2. Go to Administration → API Users
3. Create new API User
4. Copy API Key
5. Set permissions for API User

**Headers sent automatically:**
```
X-Api-Key: your_api_key_here
Content-Type: application/json
Accept: application/json
```

## Integration Examples

### In Motia Step

```python
from services.espocrm import EspoCRMAPI

config = {
    'type': 'event',
    'name': 'Sync Beteiligter to Advoware',
    'subscribes': ['vmh.beteiligte.create']
}

async def handler(event, context):
    entity_id = event['data']['entity_id']
    
    # Fetch from EspoCRM
    espo = EspoCRMAPI(context=context)
    beteiligter = await espo.get_entity('Beteiligte', entity_id)
    
    context.logger.info(f"Processing: {beteiligter['name']}")
    
    # Transform and sync to Advoware...
    # ...
```

### In Cron Step

```python
from services.espocrm import EspoCRMAPI
from datetime import datetime, timedelta

config = {
    'type': 'cron',
    'cron': '*/5 * * * *',
    'name': 'Check Expired Callbacks'
}

async def handler(input, context):
    espo = EspoCRMAPI(context=context)
    
    # Find expired callbacks
    now = datetime.utcnow().isoformat() + 'Z'
    
    result = await espo.list_entities(
        'CVmhErstgespraech',
        where=[
            {'type': 'lessThan', 'attribute': 'nchsterAnruf', 'value': now},
            {'type': 'equals', 'attribute': 'status', 'value': 'Warte auf neuen Anruf'}
        ]
    )
    
    # Update status for expired entries
    for entry in result['list']:
        await espo.update_entity(
            'CVmhErstgespraech',
            entry['id'],
            {'status': 'Neu'}
        )
        context.logger.info(f"Reset status for {entry['id']}")
```

## Helper Script: Compare Structures

Compare entity structures between EspoCRM and Advoware:

```bash
# Compare by EspoCRM ID (auto-search in Advoware)
python bitbylaw/scripts/compare_beteiligte.py 64a3f2b8c9e1234567890abc

# Compare with specific Advoware ID
python bitbylaw/scripts/compare_beteiligte.py 64a3f2b8c9e1234567890abc 12345
```

**Output:**
- Entity data from both systems
- Field structure comparison
- Suggested field mappings
- JSON output saved to `scripts/beteiligte_comparison_result.json`

## Performance

### Timeout

Default: 30 seconds (configurable via `ESPOCRM_API_TIMEOUT_SECONDS`)

```python
# Custom timeout for specific call
result = await espo.api_call('/Beteiligte', timeout_seconds=60)
```

### Pagination

```python
# Fetch in pages
offset = 0
max_size = 50

while True:
    result = await espo.list_entities(
        'Beteiligte',
        offset=offset,
        max_size=max_size
    )
    
    entities = result['list']
    if not entities:
        break
    
    # Process entities...
    
    offset += len(entities)
    
    if len(entities) < max_size:
        break  # Last page
```

### Rate Limiting

Optional Redis-based rate limiting can be implemented:

```python
# Check rate limit before API call
rate_limit_key = f'espocrm:rate_limit:{entity_type}'
if espo.redis_client:
    count = espo.redis_client.incr(rate_limit_key)
    espo.redis_client.expire(rate_limit_key, 60)  # 1 minute window
    
    if count > 100:  # Max 100 requests per minute
        raise Exception("Rate limit exceeded")
```

## Testing

```python
import pytest
from services.espocrm import EspoCRMAPI

@pytest.mark.asyncio
async def test_get_entity():
    espo = EspoCRMAPI()
    
    # Mock or use test entity ID
    result = await espo.get_entity('Contact', 'test-id-123')
    
    assert 'id' in result
    assert result['id'] == 'test-id-123'
```

## Logging

All operations are logged via context.logger:

```
[INFO] [EspoCRM] EspoCRM API initialized with base URL: https://crm.bitbylaw.com/api/v1
[DEBUG] [EspoCRM] API call: GET https://crm.bitbylaw.com/api/v1/Beteiligte/123
[DEBUG] [EspoCRM] Response status: 200
[INFO] [EspoCRM] Getting Beteiligte with ID: 123
```

## Related Files

- [services/espocrm.py](./espocrm.py) - Implementation
- [scripts/compare_beteiligte.py](../scripts/compare_beteiligte.py) - Comparison tool
- [steps/crm-bbl-vmh-reset-nextcall_step.py](../../steps/crm-bbl-vmh-reset-nextcall_step.py) - Example usage
- [config.py](../config.py) - Configuration

## EspoCRM API Documentation

Official docs: https://docs.espocrm.com/development/api/

**Key Concepts:**
- RESTful API with JSON
- Entity-based operations
- Filter operators: `equals`, `notEquals`, `greaterThan`, `lessThan`, `like`, `contains`, `in`, `isNull`, `isNotNull`
- Boolean operators: `and` (default), `or`
- Metadata API: `/Metadata` (for entity definitions)
