# BellSystems CP — Automation & Notification Engine Strategy

## Overview

This document defines the architecture and implementation plan for a three-tier intelligence layer built on top of the existing BellSystems Control Panel. The system consists of:

1. **Event Logs** — passive, timestamped record of notable system events
2. **Notifications** — real-time or near-real-time alerts surfaced in the UI
3. **Automation Rules** — trigger → condition → action pipelines, configurable via UI

The existing tech stack is unchanged: **FastAPI + SQLite (aiosqlite) + Firestore + React**. Everything new slots in as additional tables in `mqtt_data.db`, new backend modules, and new frontend pages/components.

---

## Architecture

```
┌──────────────────────────────────────────────────┐
│  Scheduler Loop (runs inside existing FastAPI    │
│  startup, alongside email_sync_loop)             │
│                                                  │
│  Every 60s: evaluate_rules()                     │
│    ↓                                             │
│  Rules Engine                                    │
│    → loads enabled rules from DB                 │
│    → evaluates conditions against live data      │
│    → fires Action Executor on match              │
│                                                  │
│  Action Executor                                 │
│    → create_event_log()                          │
│    → create_notification()                       │
│    → send_email()           (existing)           │
│    → mqtt_publish_command() (existing)           │
│    → update_field()                              │
└──────────────────────────────────────────────────┘
                     ↕ REST / WebSocket
┌──────────────────────────────────────────────────┐
│  React Frontend                                  │
│    - Bell icon in Header (unread count badge)    │
│    - Notifications dropdown/panel                │
│    - /automations page (rule CRUD)               │
│    - Event Log viewer (filterable)               │
└──────────────────────────────────────────────────┘
```

---

## Database Schema (additions to `mqtt_data.db`)

### `event_log`
Permanent, append-only record of things that happened.

```sql
CREATE TABLE IF NOT EXISTS event_log (
    id          TEXT PRIMARY KEY,
    category    TEXT NOT NULL,  -- 'device' | 'crm' | 'quotation' | 'user' | 'system'
    entity_type TEXT,           -- 'device' | 'customer' | 'quotation' | 'user'
    entity_id   TEXT,           -- the ID of the affected record
    title       TEXT NOT NULL,
    detail      TEXT,
    severity    TEXT NOT NULL DEFAULT 'info',  -- 'info' | 'warning' | 'error'
    rule_id     TEXT,           -- which automation rule triggered this (nullable)
    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_event_log_category ON event_log(category, created_at);
CREATE INDEX IF NOT EXISTS idx_event_log_entity ON event_log(entity_type, entity_id);
```

### `notifications`
Short-lived, user-facing alerts. Cleared once read or after TTL.

```sql
CREATE TABLE IF NOT EXISTS notifications (
    id          TEXT PRIMARY KEY,
    title       TEXT NOT NULL,
    body        TEXT,
    link        TEXT,           -- optional frontend route, e.g. "/crm/customers/abc123"
    severity    TEXT NOT NULL DEFAULT 'info',  -- 'info' | 'warning' | 'error' | 'success'
    is_read     INTEGER NOT NULL DEFAULT 0,
    rule_id     TEXT,
    entity_type TEXT,
    entity_id   TEXT,
    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(is_read, created_at);
```

### `automation_rules`
Stores user-defined rules. Evaluated by the scheduler.

```sql
CREATE TABLE IF NOT EXISTS automation_rules (
    id              TEXT PRIMARY KEY,
    name            TEXT NOT NULL,
    description     TEXT,
    enabled         INTEGER NOT NULL DEFAULT 1,
    trigger_type    TEXT NOT NULL,  -- 'schedule' | 'mqtt_alert' | 'email_received'
    trigger_config  TEXT NOT NULL DEFAULT '{}',  -- JSON
    conditions      TEXT NOT NULL DEFAULT '[]',  -- JSON array of condition objects
    actions         TEXT NOT NULL DEFAULT '[]',  -- JSON array of action objects
    cooldown_hours  REAL NOT NULL DEFAULT 0,     -- min hours between firing on same entity
    last_run_at     TEXT,
    run_count       INTEGER NOT NULL DEFAULT 0,
    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
);
```

### `automation_run_log`
Deduplication and audit trail for rule executions.

```sql
CREATE TABLE IF NOT EXISTS automation_run_log (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    rule_id     TEXT NOT NULL,
    entity_type TEXT,
    entity_id   TEXT,
    status      TEXT NOT NULL,  -- 'fired' | 'skipped_cooldown' | 'error'
    detail      TEXT,
    fired_at    TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_run_log_rule ON automation_run_log(rule_id, fired_at);
CREATE INDEX IF NOT EXISTS idx_run_log_entity ON automation_run_log(entity_type, entity_id, fired_at);
```

---

## Backend Module: `automation/`

New module at `backend/automation/`, registered in `main.py`.

```
backend/automation/
├── __init__.py
├── router.py        # CRUD for rules, event_log GET, notifications GET/PATCH
├── models.py        # Pydantic schemas for rules, conditions, actions
├── engine.py        # evaluate_rules(), condition evaluators, action executors
├── scheduler.py     # automation_loop() async task, wired into main.py startup
└── database.py      # DB helpers for all 4 new tables
```

### Wiring into `main.py`

```python
from automation.router import router as automation_router
from automation.scheduler import automation_loop

app.include_router(automation_router)

# In startup():
asyncio.create_task(automation_loop())
```

---

## Rule Object Structure (JSON, stored in DB)

```json
{
  "id": "rule_abc123",
  "name": "Quotation follow-up after 7 days",
  "enabled": true,
  "trigger_type": "schedule",
  "trigger_config": { "interval_hours": 24 },
  "conditions": [
    { "entity": "quotation", "field": "status",              "op": "eq",  "value": "sent" },
    { "entity": "quotation", "field": "days_since_updated",  "op": "gte", "value": 7 },
    { "entity": "quotation", "field": "has_reply",           "op": "eq",  "value": false }
  ],
  "actions": [
    {
      "type": "send_email",
      "template_key": "quotation_followup",
      "to": "{{quotation.client_email}}",
      "subject": "Following up on Quotation {{quotation.quotation_number}}",
      "body": "Hi {{customer.name}}, did you have a chance to review our quotation?"
    },
    {
      "type": "create_notification",
      "title": "Follow-up sent to {{customer.name}}",
      "link": "/crm/customers/{{quotation.customer_id}}"
    },
    {
      "type": "create_event_log",
      "category": "quotation",
      "severity": "info",
      "title": "Auto follow-up sent for {{quotation.quotation_number}}"
    }
  ],
  "cooldown_hours": 168
}
```

---

## Supported Trigger Types

| Trigger | How it works |
|---|---|
| `schedule` | Evaluated every N hours by the background loop |
| `mqtt_alert` | Fires immediately when `_handle_alerts()` in `mqtt/logger.py` upserts an alert — hook into that function |
| `email_received` | Fires inside `sync_emails()` in `crm/email_sync.py` after a new inbound email is stored |

> **Note:** `mqtt_alert` and `email_received` triggers bypass the scheduler loop — they are called directly from the relevant handler functions, giving near-real-time response.

---

## Supported Condition Operators

| op | Meaning |
|---|---|
| `eq` | equals |
| `neq` | not equals |
| `gt` / `gte` / `lt` / `lte` | numeric comparisons |
| `contains` | string contains |
| `is_null` / `not_null` | field presence |
| `days_since` | computed: (now - field_datetime) in days |

---

## Supported Action Types

| Action | What it does | Notes |
|---|---|---|
| `create_event_log` | Writes to `event_log` table | Always safe to fire |
| `create_notification` | Writes to `notifications` table | Surfaces in UI bell icon |
| `send_email` | Calls existing `crm.email_sync.send_email()` | Uses existing mail accounts |
| `update_field` | Updates a field on an entity in DB/Firestore | Use carefully — define allowed fields explicitly |
| `mqtt_publish` | Calls `mqtt_manager.publish_command()` | For device auto-actions |
| `webhook` | HTTP POST to an external URL | Future / optional |

---

## Notification System (Frontend)

### Bell Icon in Header

- Polling endpoint: `GET /api/notifications?unread=true&limit=20`
- Poll interval: 30 seconds (or switch to WebSocket push — the WS infrastructure already exists via `mqtt_manager`)
- Badge shows unread count
- Click opens a dropdown panel listing recent notifications with title, time, severity color, and optional link

### Notification Panel
- Mark as read: `PATCH /api/notifications/{id}/read`
- Mark all read: `PATCH /api/notifications/read-all`
- Link field navigates to the relevant page on click

### Toast Popups (optional, Phase 3 polish)
- Triggered by polling detecting new unread notifications since last check
- Use an existing toast component if one exists, otherwise add a lightweight one

---

## Automation Rules UI (`/automations`)

A new sidebar entry under Settings (sysadmin/admin only).

### Rule List Page
- Table: name, enabled toggle, trigger type, last run, run count, edit/delete
- "New Rule" button

### Rule Editor (modal or full page)
- **Name & description** — free text
- **Trigger** — dropdown: Schedule / MQTT Alert / Email Received
  - Schedule: interval hours input
  - MQTT Alert: subsystem filter (optional)
  - Email Received: from address filter (optional)
- **Conditions** — dynamic list, each row:
  - Entity selector (Quotation / Device / Customer / User)
  - Field selector (populated based on entity)
  - Operator dropdown
  - Value input
- **Actions** — dynamic list, each row:
  - Action type dropdown
  - Type-specific fields (to address, subject, body for email; notification title/body; etc.)
  - Template variables hint: `{{quotation.quotation_number}}`, `{{customer.name}}`, etc.
- **Cooldown** — hours between firings on the same entity
- **Enabled** toggle

### Rule Run History
- Per-rule log: when it fired, on which entity, success/error

---

## Event Log UI

Accessible from `/event-log` route, linked from Dashboard.

- Filterable by: category, severity, entity type, date range
- Columns: time, category, severity badge, title, entity link
- Append-only (no deletion from UI)
- Retention: purge entries older than configurable days (e.g. 180 days) via the existing `purge_loop` pattern in `mqtt/database.py`

---

## Pre-Built Rules (Seeded on First Run, All Disabled)

These are created on first startup — the admin enables and customizes them.

| Rule | Trigger | Condition | Action |
|---|---|---|---|
| Quotation follow-up | Schedule 24h | status=sent AND days_since_updated ≥ 7 AND no reply | Send follow-up email + notify |
| Device offline warning | Schedule 1h | no heartbeat for > 2h | Create notification + event log |
| New unknown email | email_received | customer_id IS NULL | Create notification |
| Subscription expiring soon | Schedule 24h | subscription.expiry_date within 7 days | Notify + send email |
| Device critical alert | mqtt_alert | state = CRITICAL | Notify + event log + optional MQTT restart |
| Quotation expired | Schedule 24h | status=sent AND days_since_updated ≥ 30 | Update status → expired + notify |

---

## Implementation Phases

### Phase 1 — Foundation (DB + API)
- [ ] Add 4 new tables to `mqtt/database.py` schema + migrations
- [ ] Create `automation/database.py` with all DB helpers
- [ ] Create `automation/models.py` — Pydantic schemas for rules, conditions, actions, notifications, event_log
- [ ] Create `automation/router.py` — CRUD for rules, GET event_log, GET/PATCH notifications
- [ ] Wire router into `main.py`

### Phase 2 — Rules Engine + Scheduler
- [ ] Create `automation/engine.py` — condition evaluator, template renderer, action executor
- [ ] Create `automation/scheduler.py` — `automation_loop()` async task
- [ ] Hook `email_received` trigger into `crm/email_sync.sync_emails()`
- [ ] Hook `mqtt_alert` trigger into `mqtt/logger._handle_alerts()`
- [ ] Seed pre-built (disabled) rules on first startup
- [ ] Wire `automation_loop()` into `main.py` startup

### Phase 3 — Notification UI
- [ ] Bell icon with unread badge in `Header.jsx`
- [ ] Notifications dropdown panel component
- [ ] 30s polling hook in React
- [ ] Mark read / mark all read

### Phase 4 — Automation Rules UI
- [ ] `/automations` route and rule list page
- [ ] Rule editor form (conditions + actions dynamic builder)
- [ ] Enable/disable toggle
- [ ] Run history per rule
- [ ] Add "Automations" entry to Sidebar under Settings

### Phase 5 — Event Log UI
- [ ] `/event-log` route with filterable table
- [ ] Purge policy wired into existing `purge_loop`
- [ ] Dashboard widget showing recent high-severity events

### Phase 6 — Polish
- [ ] Toast notifications on new unread detection
- [ ] Template variable previewer in rule editor
- [ ] "Run now" button per rule (for testing without waiting for scheduler)
- [ ] Named email templates stored in DB (reusable across rules)

---

## Key Design Decisions

| Decision | Choice | Reason |
|---|---|---|
| Storage | SQLite (same `mqtt_data.db`) | Consistent with existing pattern; no new infra |
| Scheduler | `asyncio` task in FastAPI startup | Same pattern as `email_sync_loop` and `purge_loop` already in `main.py` |
| Rule format | JSON columns in DB | Flexible, UI-editable, no schema migrations per new rule type |
| Template variables | `{{entity.field}}` string interpolation | Simple to implement, readable in UI |
| Cooldown dedup | `automation_run_log` per (rule_id, entity_id) | Prevents repeat firing on same quotation/device within cooldown window |
| Notification delivery | DB polling (30s) initially | The WS infra exists (`mqtt_manager._ws_subscribers`) — easy to upgrade later |
| Pre-built rules | Seeded as disabled | Non-intrusive — admin must consciously enable each one |
| `update_field` safety | Explicit allowlist of permitted fields | Prevents accidental data corruption from misconfigured rules |

---

## Template Variables Reference

Available inside action `body`, `subject`, `title`, `link` fields:

| Variable | Source |
|---|---|
| `{{customer.name}}` | Firestore `crm_customers` |
| `{{customer.organization}}` | Firestore `crm_customers` |
| `{{quotation.quotation_number}}` | SQLite `crm_quotations` |
| `{{quotation.final_total}}` | SQLite `crm_quotations` |
| `{{quotation.status}}` | SQLite `crm_quotations` |
| `{{quotation.client_email}}` | SQLite `crm_quotations` |
| `{{device.serial}}` | Firestore `devices` |
| `{{device.label}}` | Firestore `devices` |
| `{{alert.subsystem}}` | MQTT alert payload |
| `{{alert.state}}` | MQTT alert payload |
| `{{user.email}}` | Firestore `users` |

---

## Notes

- `crm/email_sync.send_email()` is reused as-is for the `send_email` action type. The engine constructs the call parameters.
- `update_field` actions start with an allowlist of: `quotation.status`, `user.status`. Expand deliberately.
- For MQTT auto-restart, `mqtt_manager.publish_command(serial, "restart", {})` already works — the engine just calls it.
- Firestore is read-only from the automation engine (for customer/device lookups). All writes go to SQLite, consistent with the existing architecture.
- The `has_reply` condition on quotations is computed by checking whether any `crm_comms_log` entry exists with `direction='inbound'` and `customer_id` matching the quotation's customer, dated after the quotation's `updated_at`.
