16 KiB
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:
- Event Logs — passive, timestamped record of notable system events
- Notifications — real-time or near-real-time alerts surfaced in the UI
- 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.
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.
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.
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.
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
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)
{
"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_alertandemail_receivedtriggers 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_looppattern inmqtt/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.pyschema + migrations - Create
automation/database.pywith 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_receivedtrigger intocrm/email_sync.sync_emails() - Hook
mqtt_alerttrigger intomqtt/logger._handle_alerts() - Seed pre-built (disabled) rules on first startup
- Wire
automation_loop()intomain.pystartup
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
/automationsroute 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-logroute 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 thesend_emailaction type. The engine constructs the call parameters.update_fieldactions 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_replycondition on quotations is computed by checking whether anycrm_comms_logentry exists withdirection='inbound'andcustomer_idmatching the quotation's customer, dated after the quotation'supdated_at.