# Data Model: Geutebruck Video Surveillance API

**Branch**: `001-surveillance-api` | **Date**: 2025-12-08
**Input**: [spec.md](./spec.md) requirements | [research.md](./research.md) technical decisions

---

## Overview

This document defines all data entities, their schemas, relationships, validation rules, and state transitions for the Geutebruck Video Surveillance API.

**Entity Categories**:
- **Authentication**: User, Session, Token
- **Surveillance**: Camera, Stream, Recording
- **Events**: Event, EventSubscription
- **Configuration**: AnalyticsConfig, PTZPreset
- **Audit**: AuditLog

---

## 1. Authentication Entities

### 1.1 User

Represents an API user with authentication credentials and permissions.

**Schema**:
```python
class User(BaseModel):
    id: UUID = Field(default_factory=uuid4)
    username: str = Field(min_length=3, max_length=50, pattern="^[a-zA-Z0-9_-]+$")
    email: EmailStr
    hashed_password: str  # bcrypt hash
    role: UserRole  # viewer, operator, administrator
    permissions: List[Permission]  # Granular camera-level permissions
    is_active: bool = True
    created_at: datetime
    updated_at: datetime
    last_login: Optional[datetime] = None

class UserRole(str, Enum):
    VIEWER = "viewer"  # Read-only camera access
    OPERATOR = "operator"  # Camera control + viewing
    ADMINISTRATOR = "administrator"  # Full system configuration

class Permission(BaseModel):
    resource_type: str  # "camera", "recording", "analytics"
    resource_id: int  # Channel ID or "*" for all
    actions: List[str]  # ["view", "ptz", "record", "configure"]
```

**Validation Rules**:
- `username`: Unique, alphanumeric with dash/underscore only
- `email`: Valid email format, unique
- `hashed_password`: Never returned in API responses
- `role`: Must be one of defined roles
- `permissions`: Empty list defaults to role-based permissions

**Relationships**:
- User → Session (one-to-many): User can have multiple active sessions
- User → AuditLog (one-to-many): All user actions logged

**Example**:
```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "username": "operator1",
  "email": "operator1@example.com",
  "role": "operator",
  "permissions": [
    {
      "resource_type": "camera",
      "resource_id": 1,
      "actions": ["view", "ptz"]
    },
    {
      "resource_type": "camera",
      "resource_id": 2,
      "actions": ["view"]
    }
  ],
  "is_active": true,
  "created_at": "2025-12-01T10:00:00Z",
  "last_login": "2025-12-08T14:30:00Z"
}
```

---

### 1.2 Session

Represents an active authentication session with JWT tokens.

**Schema**:
```python
class Session(BaseModel):
    session_id: str = Field(...)  # JTI from JWT
    user_id: UUID
    access_token_jti: str
    refresh_token_jti: Optional[str] = None
    ip_address: str
    user_agent: str
    created_at: datetime
    last_activity: datetime
    expires_at: datetime

class TokenPair(BaseModel):
    access_token: str  # JWT token string
    refresh_token: str
    token_type: str = "bearer"
    expires_in: int  # Seconds until access token expires
```

**State Transitions**:
```
Created → Active → Refreshed → Expired/Revoked
```

**Validation Rules**:
- `session_id`: Unique, UUID format
- `access_token_jti`: Must match JWT jti claim
- `ip_address`: Valid IPv4/IPv6 address
- `expires_at`: Auto-set based on JWT expiration

**Storage**: Redis with TTL matching token expiration

**Redis Keys**:
```
session:{user_id}:{session_id} → Session JSON
refresh:{user_id}:{refresh_token_jti} → Refresh token metadata
```

---

## 2. Surveillance Entities

### 2.1 Camera

Represents a video input channel/camera with capabilities and status.

**Schema**:
```python
class Camera(BaseModel):
    id: int  # Channel ID from GeViScope
    global_id: str  # GeViScope GlobalID (UUID)
    name: str = Field(min_length=1, max_length=100)
    description: Optional[str] = Field(max_length=500)
    location: Optional[str] = Field(max_length=200)
    status: CameraStatus
    capabilities: CameraCapabilities
    stream_info: Optional[StreamInfo] = None
    recording_status: RecordingStatus
    created_at: datetime
    updated_at: datetime

class CameraStatus(str, Enum):
    ONLINE = "online"
    OFFLINE = "offline"
    ERROR = "error"
    MAINTENANCE = "maintenance"

class CameraCapabilities(BaseModel):
    has_ptz: bool = False
    has_video_sensor: bool = False  # Motion detection
    has_contrast_detection: bool = False
    has_sync_detection: bool = False
    supported_analytics: List[AnalyticsType] = []
    supported_resolutions: List[str] = []  # ["1920x1080", "1280x720"]
    supported_formats: List[str] = []  # ["h264", "mjpeg"]

class AnalyticsType(str, Enum):
    VMD = "vmd"  # Video Motion Detection
    NPR = "npr"  # Number Plate Recognition
    OBTRACK = "obtrack"  # Object Tracking
    GTECT = "gtect"  # Perimeter Protection
    CPA = "cpa"  # Camera Position Analysis
```

**State Transitions**:
```
Offline ⟷ Online ⟷ Error
   ↓
Maintenance
```

**Validation Rules**:
- `id`: Positive integer, corresponds to GeViScope channel ID
- `name`: Required, user-friendly camera identifier
- `status`: Updated via SDK events
- `capabilities`: Populated from GeViScope VideoInputInfo

**Relationships**:
- Camera → Stream (one-to-many): Multiple concurrent streams per camera
- Camera → Recording (one-to-many): Recording segments for this camera
- Camera → AnalyticsConfig (one-to-one): Analytics configuration
- Camera → PTZPreset (one-to-many): Saved PTZ positions

**Example**:
```json
{
  "id": 5,
  "global_id": "a7b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6",
  "name": "Entrance Camera",
  "description": "Main entrance monitoring",
  "location": "Building A - Main Entrance",
  "status": "online",
  "capabilities": {
    "has_ptz": true,
    "has_video_sensor": true,
    "has_contrast_detection": true,
    "has_sync_detection": true,
    "supported_analytics": ["vmd", "obtrack"],
    "supported_resolutions": ["1920x1080", "1280x720"],
    "supported_formats": ["h264", "mjpeg"]
  },
  "recording_status": {
    "is_recording": true,
    "mode": "continuous",
    "start_time": "2025-12-08T00:00:00Z"
  }
}
```

---

### 2.2 Stream

Represents an active video stream session.

**Schema**:
```python
class Stream(BaseModel):
    stream_id: UUID = Field(default_factory=uuid4)
    camera_id: int  # Channel ID
    user_id: UUID
    stream_url: HttpUrl  # Authenticated URL to GeViScope stream
    format: str  # "h264", "mjpeg"
    resolution: str  # "1920x1080"
    fps: int = Field(ge=1, le=60)
    quality: int = Field(ge=1, le=100)  # Quality percentage
    started_at: datetime
    last_activity: datetime
    expires_at: datetime
    status: StreamStatus

class StreamStatus(str, Enum):
    INITIALIZING = "initializing"
    ACTIVE = "active"
    PAUSED = "paused"
    STOPPED = "stopped"
    ERROR = "error"

class StreamRequest(BaseModel):
    """Request model for initiating a stream"""
    format: str = "h264"
    resolution: Optional[str] = None  # Default: camera's max resolution
    fps: Optional[int] = None  # Default: camera's max FPS
    quality: int = Field(default=90, ge=1, le=100)

class StreamResponse(BaseModel):
    """Response containing stream access details"""
    stream_id: UUID
    camera_id: int
    stream_url: HttpUrl  # Token-authenticated URL
    format: str
    resolution: str
    fps: int
    expires_at: datetime  # Stream URL expiration
    websocket_url: Optional[HttpUrl] = None  # For WebSocket-based streams
```

**State Transitions**:
```
Initializing → Active ⟷ Paused → Stopped
                 ↓
              Error
```

**Validation Rules**:
- `camera_id`: Must reference existing, online camera
- `stream_url`: Contains time-limited JWT token
- `expires_at`: Default 1 hour from creation
- `format`, `resolution`, `fps`: Must be supported by camera

**Lifecycle**:
1. Client requests stream: `POST /cameras/{id}/stream`
2. API generates token-authenticated URL
3. Client connects directly to GeViScope stream URL
4. Stream auto-expires after TTL
5. Client can extend via API: `POST /streams/{id}/extend`

---

### 2.3 Recording

Represents a video recording segment.

**Schema**:
```python
class Recording(BaseModel):
    id: UUID = Field(default_factory=uuid4)
    camera_id: int
    start_time: datetime
    end_time: Optional[datetime] = None  # None if still recording
    duration_seconds: Optional[int] = None
    file_size_bytes: Optional[int] = None
    trigger: RecordingTrigger
    status: RecordingStatus
    export_url: Optional[HttpUrl] = None  # If exported
    metadata: RecordingMetadata
    created_at: datetime

class RecordingTrigger(str, Enum):
    SCHEDULED = "scheduled"  # Time-based schedule
    EVENT = "event"  # Triggered by alarm/analytics
    MANUAL = "manual"  # User-initiated
    CONTINUOUS = "continuous"  # Always recording

class RecordingStatus(str, Enum):
    RECORDING = "recording"
    COMPLETED = "completed"
    FAILED = "failed"
    EXPORTING = "exporting"
    EXPORTED = "exported"

class RecordingMetadata(BaseModel):
    event_id: Optional[str] = None  # If event-triggered
    pre_alarm_seconds: int = 0
    post_alarm_seconds: int = 0
    tags: List[str] = []
    notes: Optional[str] = None

class RecordingQueryParams(BaseModel):
    camera_id: Optional[int] = None
    start_time: Optional[datetime] = None
    end_time: Optional[datetime] = None
    trigger: Optional[RecordingTrigger] = None
    limit: int = Field(default=50, ge=1, le=1000)
    offset: int = Field(default=0, ge=0)
```

**State Transitions**:
```
Recording → Completed
             ↓
          Exporting → Exported
             ↓
          Failed
```

**Validation Rules**:
- `camera_id`: Must exist
- `start_time`: Cannot be in future
- `end_time`: Must be after start_time
- `file_size_bytes`: Calculated from ring buffer

**Relationships**:
- Recording → Camera (many-to-one)
- Recording → Event (many-to-one, optional)

**Ring Buffer Handling**:
- Oldest recordings automatically deleted when buffer full
- `retention_policy` determines minimum retention period
- API exposes capacity warnings

---

## 3. Event Entities

### 3.1 Event

Represents a surveillance event (alarm, analytics, system).

**Schema**:
```python
class Event(BaseModel):
    id: UUID = Field(default_factory=uuid4)
    event_type: EventType
    camera_id: Optional[int] = None  # None for system events
    timestamp: datetime
    severity: EventSeverity
    data: EventData  # Type-specific event data
    foreign_key: Optional[str] = None  # External system correlation
    acknowledged: bool = False
    acknowledged_by: Optional[UUID] = None
    acknowledged_at: Optional[datetime] = None

class EventType(str, Enum):
    # Analytics events
    MOTION_DETECTED = "motion_detected"
    OBJECT_TRACKED = "object_tracked"
    LICENSE_PLATE = "license_plate"
    PERIMETER_BREACH = "perimeter_breach"
    CAMERA_TAMPER = "camera_tamper"

    # System events
    CAMERA_ONLINE = "camera_online"
    CAMERA_OFFLINE = "camera_offline"
    RECORDING_STARTED = "recording_started"
    RECORDING_STOPPED = "recording_stopped"
    STORAGE_WARNING = "storage_warning"

    # Alarm events
    ALARM_TRIGGERED = "alarm_triggered"
    ALARM_CLEARED = "alarm_cleared"

class EventSeverity(str, Enum):
    INFO = "info"
    WARNING = "warning"
    ERROR = "error"
    CRITICAL = "critical"

class EventData(BaseModel):
    """Base class for type-specific event data"""
    pass

class MotionDetectedData(EventData):
    zone: str
    confidence: float = Field(ge=0.0, le=1.0)
    snapshot_url: Optional[HttpUrl] = None

class LicensePlateData(EventData):
    plate_number: str
    country_code: str
    confidence: float = Field(ge=0.0, le=1.0)
    snapshot_url: Optional[HttpUrl] = None
    is_watchlist_match: bool = False

class ObjectTrackedData(EventData):
    tracking_id: str
    object_type: str  # "person", "vehicle"
    zone_entered: Optional[str] = None
    zone_exited: Optional[str] = None
    dwell_time_seconds: Optional[int] = None
```

**Validation Rules**:
- `event_type`: Must be valid EventType
- `camera_id`: Required for camera events, None for system events
- `timestamp`: Auto-set to current time if not provided
- `severity`: Must match event type severity mapping

**Relationships**:
- Event → Camera (many-to-one, optional)
- Event → Recording (one-to-one, optional)
- Event → User (acknowledged_by, many-to-one, optional)

**Example**:
```json
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "event_type": "motion_detected",
  "camera_id": 5,
  "timestamp": "2025-12-08T14:45:23Z",
  "severity": "warning",
  "data": {
    "zone": "entrance",
    "confidence": 0.95,
    "snapshot_url": "https://api.example.com/snapshots/abc123.jpg"
  },
  "acknowledged": false
}
```

---

### 3.2 EventSubscription

Represents a WebSocket client's event subscription.

**Schema**:
```python
class EventSubscription(BaseModel):
    subscription_id: UUID = Field(default_factory=uuid4)
    user_id: UUID
    connection_id: str  # WebSocket connection identifier
    filters: EventFilter
    created_at: datetime
    last_heartbeat: datetime

class EventFilter(BaseModel):
    event_types: Optional[List[EventType]] = None  # None = all types
    camera_ids: Optional[List[int]] = None  # None = all cameras
    severity: Optional[EventSeverity] = None  # Minimum severity
    include_acknowledged: bool = False

class EventNotification(BaseModel):
    """WebSocket message format"""
    subscription_id: UUID
    event: Event
    sequence_number: int  # For detecting missed events
```

**Validation Rules**:
- `filters.camera_ids`: User must have view permission for each camera
- `last_heartbeat`: Updated every 30 seconds, timeout after 90 seconds

**Storage**: Redis with 5-minute retention for reconnection

---

## 4. Configuration Entities

### 4.1 AnalyticsConfig

Configuration for video analytics on a camera.

**Schema**:
```python
class AnalyticsConfig(BaseModel):
    camera_id: int
    analytics_type: AnalyticsType
    enabled: bool = False
    config: AnalyticsTypeConfig  # Type-specific configuration
    updated_at: datetime
    updated_by: UUID

class VMDConfig(AnalyticsTypeConfig):
    """Video Motion Detection configuration"""
    zones: List[DetectionZone]
    sensitivity: int = Field(ge=1, le=10, default=5)
    min_object_size: int = Field(ge=1, le=100, default=10)  # Percentage
    ignore_zones: List[DetectionZone] = []

class DetectionZone(BaseModel):
    name: str
    polygon: List[Point]  # List of x,y coordinates

class Point(BaseModel):
    x: int = Field(ge=0, le=100)  # Percentage of frame width
    y: int = Field(ge=0, le=100)  # Percentage of frame height

class NPRConfig(AnalyticsTypeConfig):
    """Number Plate Recognition configuration"""
    zones: List[DetectionZone]
    country_codes: List[str] = ["*"]  # ["US", "DE"] or "*" for all
    min_confidence: float = Field(ge=0.0, le=1.0, default=0.7)
    watchlist: List[str] = []  # List of plate numbers to alert on

class OBTRACKConfig(AnalyticsTypeConfig):
    """Object Tracking configuration"""
    object_types: List[str] = ["person", "vehicle"]
    min_dwell_time: int = Field(ge=0, default=5)  # Seconds
    count_lines: List[CountLine] = []

class CountLine(BaseModel):
    name: str
    point_a: Point
    point_b: Point
    direction: str  # "in", "out", "both"
```

**Validation Rules**:
- `camera_id`: Must exist and support analytics_type
- `zones`: At least one zone required when enabled=True
- `polygon`: Minimum 3 points, closed polygon
- `sensitivity`: Higher = more sensitive

**Example**:
```json
{
  "camera_id": 5,
  "analytics_type": "vmd",
  "enabled": true,
  "config": {
    "zones": [
      {
        "name": "entrance",
        "polygon": [
          {"x": 10, "y": 10},
          {"x": 90, "y": 10},
          {"x": 90, "y": 90},
          {"x": 10, "y": 90}
        ]
      }
    ],
    "sensitivity": 7,
    "min_object_size": 5
  },
  "updated_at": "2025-12-08T10:00:00Z"
}
```

---

### 4.2 PTZPreset

Saved PTZ camera position.

**Schema**:
```python
class PTZPreset(BaseModel):
    id: int  # Preset ID (1-255)
    camera_id: int
    name: str = Field(min_length=1, max_length=50)
    pan: int = Field(ge=-180, le=180)  # Degrees
    tilt: int = Field(ge=-90, le=90)  # Degrees
    zoom: int = Field(ge=0, le=100)  # Percentage
    created_at: datetime
    created_by: UUID
    updated_at: datetime

class PTZCommand(BaseModel):
    """PTZ control command"""
    action: PTZAction
    speed: Optional[int] = Field(default=50, ge=1, le=100)
    preset_id: Optional[int] = None  # For goto_preset action

class PTZAction(str, Enum):
    PAN_LEFT = "pan_left"
    PAN_RIGHT = "pan_right"
    TILT_UP = "tilt_up"
    TILT_DOWN = "tilt_down"
    ZOOM_IN = "zoom_in"
    ZOOM_OUT = "zoom_out"
    STOP = "stop"
    GOTO_PRESET = "goto_preset"
    SAVE_PRESET = "save_preset"
```

**Validation Rules**:
- `id`: Unique per camera, 1-255 range
- `camera_id`: Must exist and have PTZ capability
- `name`: Unique per camera

---

## 5. Audit Entities

### 5.1 AuditLog

Audit trail for all privileged operations.

**Schema**:
```python
class AuditLog(BaseModel):
    id: UUID = Field(default_factory=uuid4)
    timestamp: datetime
    user_id: UUID
    username: str
    action: str  # "camera.ptz", "recording.start", "user.create"
    resource_type: str  # "camera", "recording", "user"
    resource_id: str
    outcome: AuditOutcome
    ip_address: str
    user_agent: str
    details: Optional[dict] = None  # Action-specific metadata

class AuditOutcome(str, Enum):
    SUCCESS = "success"
    FAILURE = "failure"
    PARTIAL = "partial"
```

**Validation Rules**:
- `timestamp`: Auto-set, immutable
- `user_id`: Must exist
- `action`: Format "{resource}.{operation}"

**Storage**: Append-only, never deleted, indexed by user_id and timestamp

**Example**:
```json
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "timestamp": "2025-12-08T14:50:00Z",
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "username": "operator1",
  "action": "camera.ptz",
  "resource_type": "camera",
  "resource_id": "5",
  "outcome": "success",
  "ip_address": "192.168.1.100",
  "user_agent": "Mozilla/5.0...",
  "details": {
    "ptz_action": "pan_left",
    "speed": 50
  }
}
```

---

## Entity Relationships Diagram

```
┌─────────┐       ┌─────────────┐       ┌────────────┐
│  User   │──1:N──│   Session   │       │  AuditLog  │
└────┬────┘       └─────────────┘       └─────┬──────┘
     │                                         │
     │1:N                                      │N:1
     │                                         │
     ▼                                         │
┌─────────────────┐                           │
│ EventSubscription│                           │
└─────────────────┘                           │
                                               │
┌────────────┐       ┌─────────┐       ┌──────▼─────┐
│  Camera    │──1:N──│ Stream  │       │  Recording │
└─────┬──────┘       └─────────┘       └──────┬─────┘
      │                                        │
      │1:N                                     │N:1
      ▼                                        │
┌─────────────────┐                           │
│ AnalyticsConfig │                           │
└─────────────────┘                           │
      │                                        │
      │1:N                                     │
      ▼                                        ▼
┌─────────────┐       ┌────────────────────────────┐
│ PTZPreset   │       │         Event              │
└─────────────┘       └────────────────────────────┘
```

---

## Validation Rules Summary

| Entity | Key Validations |
|--------|-----------------|
| User | Unique username/email, valid role, bcrypt password |
| Session | Valid JWT, IP address, TTL enforced |
| Camera | Valid channel ID, status from SDK, capabilities match |
| Stream | Camera online, token authentication, supported formats |
| Recording | Valid time range, camera exists, ring buffer aware |
| Event | Valid type, severity, camera permissions |
| EventSubscription | User has camera permissions |
| AnalyticsConfig | Camera supports type, valid zones/settings |
| PTZPreset | Camera has PTZ, valid coordinates |
| AuditLog | Immutable, complete metadata |

---

## State Machine Definitions

### Camera Status State Machine
```
[Offline] ──detect_online──▶ [Online] ──detect_offline──▶ [Offline]
                              │    ▲                        │
                              │    └───recover_error────────┘
                              │
                              └──detect_error──▶ [Error]
                                                    │
                              ┌─────────────────────┘
                              ▼
                         [Maintenance] ──restore──▶ [Online]
```

### Recording Status State Machine
```
[Recording] ──complete──▶ [Completed] ──export──▶ [Exporting] ──finish──▶ [Exported]
     │                                                 │
     └──error──▶ [Failed] ◀──────────────────────────┘
```

### Stream Status State Machine
```
[Initializing] ──ready──▶ [Active] ──pause──▶ [Paused] ──resume──▶ [Active]
                            │                                         │
                            └──stop──▶ [Stopped] ◀──────────────────┘
                            │
                            └──error──▶ [Error]
```

---

**Phase 1 Status**: ✅ Data model complete
**Next**: Generate OpenAPI contracts
