diff --git a/.claude/backend-mqtt-alerts-prompt.md b/.claude/backend-mqtt-alerts-prompt.md deleted file mode 100644 index c95730f..0000000 --- a/.claude/backend-mqtt-alerts-prompt.md +++ /dev/null @@ -1,153 +0,0 @@ -# Backend Task: Subscribe to Vesper MQTT Alert Topics - -> Use this document as a prompt / task brief for implementing the backend side -> of the Vesper MQTT alert system. The firmware changes are complete. -> Full topic spec: `docs/reference/mqtt-events.md` - ---- - -## What the firmware now publishes - -The Vesper firmware (v155+) publishes on three status topics: - -### 1. `vesper/{device_id}/status/heartbeat` (unchanged) -- Every 30 seconds, retained, QoS 1 -- You already handle this — **no change needed** except: suppress any log entry / display update triggered by heartbeat arrival. Update `last_seen` silently. Only surface an event when the device goes *silent* (no heartbeat for 90s). - -### 2. `vesper/{device_id}/status/alerts` (NEW) -- Published only when a subsystem state changes (HEALTHY → WARNING, WARNING → CRITICAL, etc.) -- QoS 1, not retained -- One message per state transition — not repeated until state changes again - -**Alert payload:** -```json -{ "subsystem": "FileManager", "state": "WARNING", "msg": "ConfigManager health check failed" } -``` -**Cleared payload (recovery):** -```json -{ "subsystem": "FileManager", "state": "CLEARED" } -``` - -### 3. `vesper/{device_id}/status/info` (NEW) -- Published on significant device state changes (playback start/stop, etc.) -- QoS 0, not retained - -```json -{ "type": "playback_started", "payload": { "melody_uid": "ABC123" } } -``` - ---- - -## What to implement in the backend (FastAPI + MQTT) - -### Subscribe to new topics - -Add to your MQTT subscription list: -```python -client.subscribe("vesper/+/status/alerts", qos=1) -client.subscribe("vesper/+/status/info", qos=0) -``` - -### Database model — active alerts per device - -Create a table (or document) to store the current alert state per device: - -```sql -CREATE TABLE device_alerts ( - device_id TEXT NOT NULL, - subsystem TEXT NOT NULL, - state TEXT NOT NULL, -- WARNING | CRITICAL | FAILED - message TEXT, - updated_at TIMESTAMP NOT NULL, - PRIMARY KEY (device_id, subsystem) -); -``` - -Or equivalent in your ORM / MongoDB / Redis structure. - -### MQTT message handler — alerts topic - -```python -def on_alerts_message(device_id: str, payload: dict): - subsystem = payload["subsystem"] - state = payload["state"] - message = payload.get("msg", "") - - if state == "CLEARED": - # Remove alert from active set - db.device_alerts.delete(device_id=device_id, subsystem=subsystem) - else: - # Upsert — create or update - db.device_alerts.upsert( - device_id = device_id, - subsystem = subsystem, - state = state, - message = message, - updated_at = now() - ) - - # Optionally push a WebSocket event to the console UI - ws_broadcast(device_id, {"event": "alert_update", "subsystem": subsystem, "state": state}) -``` - -### MQTT message handler — info topic - -```python -def on_info_message(device_id: str, payload: dict): - event_type = payload["type"] - data = payload.get("payload", {}) - - # Store or forward as needed — e.g. update device playback state - if event_type == "playback_started": - db.devices.update(device_id, playback_active=True, melody_uid=data.get("melody_uid")) - elif event_type == "playback_stopped": - db.devices.update(device_id, playback_active=False, melody_uid=None) -``` - -### API endpoint — get active alerts for a device - -``` -GET /api/devices/{device_id}/alerts -``` - -Returns the current active alert set (the upserted rows from the table above): - -```json -[ - { "subsystem": "FileManager", "state": "WARNING", "message": "SD mount failed", "updated_at": "..." }, - { "subsystem": "TimeKeeper", "state": "WARNING", "message": "NTP sync failed", "updated_at": "..." } -] -``` - -An empty array means the device is fully healthy (no active alerts). - -### Console UI guidance - -- Device list: show a coloured dot next to each device (green = no alerts, yellow = warnings, red = critical/failed). Update via WebSocket push. -- Device detail page: show an "Active Alerts" section that renders the alert set statically. Do not render a scrolling alert log — just the current state. -- When a `CLEARED` event arrives, remove the entry from the UI immediately. - ---- - -## What NOT to do - -- **Do not log every heartbeat** as a visible event. Heartbeats are internal housekeeping. -- **Do not poll the device** for health status — the device pushes on change. -- **Do not store alerts as an append-only log** — upsert by `(device_id, subsystem)`. The server holds the current state, not a history. - ---- - -## Testing - -1. Flash a device with firmware v155+ -2. Subscribe manually: - ```bash - mosquitto_sub -h -t "vesper/+/status/alerts" -v - mosquitto_sub -h -t "vesper/+/status/info" -v - ``` -3. Remove the SD card from the device — expect a `FileManager` `WARNING` alert within 5 minutes (next health check cycle), or trigger it immediately via: - ```json - { "v": 2, "cmd": "system.health" } - ``` - sent to `vesper/{device_id}/control` -4. Reinsert the SD card — expect a `FileManager` `CLEARED` alert on the next health check diff --git a/.claude/crm-build-plan.md b/.claude/crm-build-plan.md deleted file mode 100644 index f42f7c2..0000000 --- a/.claude/crm-build-plan.md +++ /dev/null @@ -1,243 +0,0 @@ -# BellSystems CRM — Build Plan & Step Prompts - -## Overview - -A bespoke CRM module built directly into the existing BellSystems web console. -Stack: FastAPI backend (Firestore), React + Vite frontend. -No new auth — uses the existing JWT + permission system. -No file storage on VPS — all media lives on Nextcloud via WebDAV. - ---- - -## Architecture Summary - -### Backend -- New module: `backend/crm/` with `models.py`, `service.py`, `router.py` -- Firestore collections: `crm_customers`, `crm_orders`, `crm_products` -- SQLite (existing `mqtt_data.db`) for comms_log (high-write, queryable) -- Router registered in `backend/main.py` as `/api/crm` - -### Frontend -- New section: `frontend/src/crm/` -- Routes added to `frontend/src/App.jsx` -- Nav entries added to `frontend/src/layout/Sidebar.jsx` - -### Integrations (later steps) -- Nextcloud: WebDAV via `httpx` in backend -- Email: IMAP (read) + SMTP (send) via `imaplib` / `smtplib` -- WhatsApp: Meta Cloud API webhook -- FreePBX: Asterisk AMI socket listener - ---- - -## Data Model Reference - -### `crm_customers` (Firestore) -```json -{ - "id": "auto", - "name": "Στέλιος Μπιμπης", - "organization": "Ενορία Αγ. Παρασκευής", - "contacts": [ - { "type": "email", "label": "personal", "value": "...", "primary": true }, - { "type": "phone", "label": "mobile", "value": "...", "primary": true } - ], - "notes": [ - { "text": "...", "by": "user_name", "at": "ISO datetime" } - ], - "location": { "city": "", "country": "", "region": "" }, - "language": "el", - "tags": [], - "owned_items": [ - { "type": "console_device", "device_id": "UID", "label": "..." }, - { "type": "product", "product_id": "pid", "product_name": "...", "quantity": 1, "serial_numbers": [] }, - { "type": "freetext", "description": "...", "serial_number": "", "notes": "" } - ], - "linked_user_ids": [], - "nextcloud_folder": "05_Customers/FOLDER_NAME", - "created_at": "ISO", - "updated_at": "ISO" -} -``` - -### `crm_orders` (Firestore) -```json -{ - "id": "auto", - "customer_id": "ref", - "order_number": "ORD-2026-001", - "status": "draft", - "items": [ - { - "type": "console_device|product|freetext", - "product_id": "", - "product_name": "", - "description": "", - "quantity": 1, - "unit_price": 0.0, - "serial_numbers": [] - } - ], - "subtotal": 0.0, - "discount": { "type": "percentage|fixed", "value": 0, "reason": "" }, - "total_price": 0.0, - "currency": "EUR", - "shipping": { - "method": "", - "tracking_number": "", - "carrier": "", - "shipped_at": null, - "delivered_at": null, - "destination": "" - }, - "payment_status": "pending", - "invoice_path": "", - "notes": "", - "created_at": "ISO", - "updated_at": "ISO" -} -``` - -### `crm_products` (Firestore) -```json -{ - "id": "auto", - "name": "Vesper Plus", - "sku": "VSP-001", - "category": "controller|striker|clock|part|repair_service", - "description": "", - "price": 0.0, - "currency": "EUR", - "costs": { - "pcb": 0.0, "components": 0.0, "enclosure": 0.0, - "labor_hours": 0, "labor_rate": 0.0, "shipping_in": 0.0, - "total": 0.0 - }, - "stock": { "on_hand": 0, "reserved": 0, "available": 0 }, - "nextcloud_folder": "02_Products/FOLDER", - "linked_device_type": "", - "active": true, - "created_at": "ISO", - "updated_at": "ISO" -} -``` - -### `crm_comms_log` (SQLite table — existing mqtt_data.db) -```sql -CREATE TABLE crm_comms_log ( - id TEXT PRIMARY KEY, - customer_id TEXT NOT NULL, - type TEXT NOT NULL, -- email|whatsapp|call|sms|note|in_person - direction TEXT NOT NULL, -- inbound|outbound|internal - subject TEXT, - body TEXT, - attachments TEXT, -- JSON array of {filename, nextcloud_path} - ext_message_id TEXT, -- IMAP uid, WhatsApp msg id, AMI call id - logged_by TEXT, - occurred_at TEXT NOT NULL, - created_at TEXT NOT NULL -); -``` - -### `crm_media` (SQLite table — existing mqtt_data.db) -```sql -CREATE TABLE crm_media ( - id TEXT PRIMARY KEY, - customer_id TEXT, - order_id TEXT, - filename TEXT NOT NULL, - nextcloud_path TEXT NOT NULL, - mime_type TEXT, - direction TEXT, -- received|sent|internal - tags TEXT, -- JSON array - uploaded_by TEXT, - created_at TEXT NOT NULL -); -``` - ---- - -## IMPORTANT NOTES FOR ALL STEPS - -- **Backend location**: `c:\development\bellsystems-cp\backend\` -- **Frontend location**: `c:\development\bellsystems-cp\frontend\` -- **Auth pattern**: All routes use `Depends(require_permission("crm", "view"))` or `"edit"`. Import from `auth.dependencies`. -- **Firestore pattern**: Use `from shared.firebase import get_db`. See `backend/devices/service.py` for reference patterns. -- **SQLite pattern**: Use `from mqtt import database as mqtt_db` — `mqtt_db.db` is the aiosqlite connection. See `backend/mqtt/database.py`. -- **Frontend auth**: `getAuthHeaders()` from `../api/auth` gives Bearer token headers. See any existing page for pattern. -- **Frontend routing**: Routes live in `frontend/src/App.jsx`. Sidebar nav in `frontend/src/layout/Sidebar.jsx`. -- **Token**: localStorage key is `"access_token"`. -- **UI pattern**: Use existing component style — `SectionCard`, `FieldRow`, inline styles for grids. See `frontend/src/devices/` for reference. -- **No new dependencies unless absolutely necessary.** - ---- - -## Step 1 — Backend: CRM Module Scaffold + Products CRUD - -**File**: `.claude/crm-step-01.md` - ---- - -## Step 2 — Backend: Customers CRUD - -**File**: `.claude/crm-step-02.md` - ---- - -## Step 3 — Backend: Orders CRUD - -**File**: `.claude/crm-step-03.md` - ---- - -## Step 4 — Backend: Comms Log + Media (SQLite) - -**File**: `.claude/crm-step-04.md` - ---- - -## Step 5 — Frontend: Products Module - -**File**: `.claude/crm-step-05.md` - ---- - -## Step 6 — Frontend: Customers List + Detail Page - -**File**: `.claude/crm-step-06.md` - ---- - -## Step 7 — Frontend: Orders Module - -**File**: `.claude/crm-step-07.md` - ---- - -## Step 8 — Frontend: Comms Log + Media Tab (manual entry) - -**File**: `.claude/crm-step-08.md` - ---- - -## Step 9 — Integration: Nextcloud WebDAV - -**File**: `.claude/crm-step-09.md` - ---- - -## Step 10 — Integration: IMAP/SMTP Email - -**File**: `.claude/crm-step-10.md` - ---- - -## Step 11 — Integration: WhatsApp Business API - -**File**: `.claude/crm-step-11.md` - ---- - -## Step 12 — Integration: FreePBX AMI Call Logging - -**File**: `.claude/crm-step-12.md` diff --git a/.claude/crm-step-01.md b/.claude/crm-step-01.md deleted file mode 100644 index 96c0c15..0000000 --- a/.claude/crm-step-01.md +++ /dev/null @@ -1,49 +0,0 @@ -# CRM Step 01 — Backend: Module Scaffold + Products CRUD - -## Context -Read `.claude/crm-build-plan.md` first for full data models, conventions, and IMPORTANT NOTES. - -## Task -Create the `backend/crm/` module with Products CRUD. This is the first CRM backend step. - -## What to build - -### 1. `backend/crm/__init__.py` — empty - -### 2. `backend/crm/models.py` -Pydantic models for Products: -- `ProductCosts` — pcb, components, enclosure, labor_hours, labor_rate, shipping_in, total (all float/int, all optional) -- `ProductStock` — on_hand, reserved, available (int, defaults 0) -- `ProductCategory` enum — controller, striker, clock, part, repair_service -- `ProductCreate` — name, sku (optional), category, description (optional), price (float), currency (default "EUR"), costs (ProductCosts optional), stock (ProductStock optional), nextcloud_folder (optional), linked_device_type (optional), active (bool default True) -- `ProductUpdate` — all fields Optional -- `ProductInDB` — extends ProductCreate + id (str), created_at (str), updated_at (str) -- `ProductListResponse` — products: List[ProductInDB], total: int - -### 3. `backend/crm/service.py` -Firestore collection: `crm_products` -Functions: -- `list_products(search=None, category=None, active_only=False) -> List[ProductInDB]` -- `get_product(product_id) -> ProductInDB` — raises HTTPException 404 if not found -- `create_product(data: ProductCreate) -> ProductInDB` — generates UUID id, sets created_at/updated_at to ISO now -- `update_product(product_id, data: ProductUpdate) -> ProductInDB` — partial update (only set fields), updates updated_at -- `delete_product(product_id) -> None` — raises 404 if not found - -### 4. `backend/crm/router.py` -Prefix: `/api/crm/products`, tag: `crm-products` -All routes require `require_permission("crm", "view")` for GET, `require_permission("crm", "edit")` for POST/PUT/DELETE. -- `GET /` → list_products (query params: search, category, active_only) -- `GET /{product_id}` → get_product -- `POST /` → create_product -- `PUT /{product_id}` → update_product -- `DELETE /{product_id}` → delete_product - -### 5. Register in `backend/main.py` -Add: `from crm.router import router as crm_products_router` -Add: `app.include_router(crm_products_router)` (after existing routers) - -## Notes -- Use `uuid.uuid4()` for IDs -- Use `datetime.utcnow().isoformat()` for timestamps -- Follow exact Firestore pattern from `backend/devices/service.py` -- No new pip dependencies needed diff --git a/.claude/crm-step-02.md b/.claude/crm-step-02.md deleted file mode 100644 index ec3a18c..0000000 --- a/.claude/crm-step-02.md +++ /dev/null @@ -1,61 +0,0 @@ -# CRM Step 02 — Backend: Customers CRUD - -## Context -Read `.claude/crm-build-plan.md` first for full data models, conventions, and IMPORTANT NOTES. -Step 01 must be complete (`backend/crm/` module exists). - -## Task -Add Customers models, service, and router to `backend/crm/`. - -## What to build - -### 1. Add to `backend/crm/models.py` - -**Contact entry:** -- `ContactType` enum — email, phone, whatsapp, other -- `CustomerContact` — type (ContactType), label (str, e.g. "personal"/"church"), value (str), primary (bool default False) - -**Note entry:** -- `CustomerNote` — text (str), by (str), at (str ISO datetime) - -**Owned items (3 tiers):** -- `OwnedItemType` enum — console_device, product, freetext -- `OwnedItem`: - - type: OwnedItemType - - For console_device: device_id (Optional[str]), label (Optional[str]) - - For product: product_id (Optional[str]), product_name (Optional[str]), quantity (Optional[int]), serial_numbers (Optional[List[str]]) - - For freetext: description (Optional[str]), serial_number (Optional[str]), notes (Optional[str]) - -**Location:** -- `CustomerLocation` — city (Optional[str]), country (Optional[str]), region (Optional[str]) - -**Customer models:** -- `CustomerCreate` — name (str), organization (Optional[str]), contacts (List[CustomerContact] default []), notes (List[CustomerNote] default []), location (Optional[CustomerLocation]), language (str default "el"), tags (List[str] default []), owned_items (List[OwnedItem] default []), linked_user_ids (List[str] default []), nextcloud_folder (Optional[str]) -- `CustomerUpdate` — all fields Optional -- `CustomerInDB` — extends CustomerCreate + id, created_at, updated_at -- `CustomerListResponse` — customers: List[CustomerInDB], total: int - -### 2. Add to `backend/crm/service.py` -Firestore collection: `crm_customers` -Functions: -- `list_customers(search=None, tag=None) -> List[CustomerInDB]` - - search matches against name, organization, and any contact value -- `get_customer(customer_id) -> CustomerInDB` — 404 if not found -- `create_customer(data: CustomerCreate) -> CustomerInDB` -- `update_customer(customer_id, data: CustomerUpdate) -> CustomerInDB` -- `delete_customer(customer_id) -> None` - -### 3. Add to `backend/crm/router.py` -Add a second router or extend existing file with prefix `/api/crm/customers`: -- `GET /` — list_customers (query: search, tag) -- `GET /{customer_id}` — get_customer -- `POST /` — create_customer -- `PUT /{customer_id}` — update_customer -- `DELETE /{customer_id}` — delete_customer - -Register this router in `backend/main.py` alongside the products router. - -## Notes -- OwnedItem is a flexible struct — store all fields, service doesn't validate which fields are relevant per type (frontend handles that) -- linked_user_ids are Firebase Auth UIDs (strings) — no validation needed here, just store them -- Search in list_customers: do client-side filter after fetching all (small dataset) diff --git a/.claude/crm-step-03.md b/.claude/crm-step-03.md deleted file mode 100644 index 979c21e..0000000 --- a/.claude/crm-step-03.md +++ /dev/null @@ -1,60 +0,0 @@ -# CRM Step 03 — Backend: Orders CRUD - -## Context -Read `.claude/crm-build-plan.md` first for full data models, conventions, and IMPORTANT NOTES. -Steps 01 and 02 must be complete. - -## Task -Add Orders models, service, and router to `backend/crm/`. - -## What to build - -### 1. Add to `backend/crm/models.py` - -**Enums:** -- `OrderStatus` — draft, confirmed, in_production, shipped, delivered, cancelled -- `PaymentStatus` — pending, partial, paid - -**Structs:** -- `OrderDiscount` — type (str: "percentage" | "fixed"), value (float default 0), reason (Optional[str]) -- `OrderShipping` — method (Optional[str]), tracking_number (Optional[str]), carrier (Optional[str]), shipped_at (Optional[str]), delivered_at (Optional[str]), destination (Optional[str]) -- `OrderItem`: - - type: str (console_device | product | freetext) - - product_id: Optional[str] - - product_name: Optional[str] - - description: Optional[str] ← for freetext items - - quantity: int default 1 - - unit_price: float default 0.0 - - serial_numbers: List[str] default [] - -**Order models:** -- `OrderCreate` — customer_id (str), order_number (Optional[str] — auto-generated if not provided), status (OrderStatus default draft), items (List[OrderItem] default []), subtotal (float default 0), discount (Optional[OrderDiscount]), total_price (float default 0), currency (str default "EUR"), shipping (Optional[OrderShipping]), payment_status (PaymentStatus default pending), invoice_path (Optional[str]), notes (Optional[str]) -- `OrderUpdate` — all fields Optional -- `OrderInDB` — extends OrderCreate + id, created_at, updated_at -- `OrderListResponse` — orders: List[OrderInDB], total: int - -### 2. Add to `backend/crm/service.py` -Firestore collection: `crm_orders` - -Auto order number generation: `ORD-{YYYY}-{NNN}` — query existing orders for current year, increment max. - -Functions: -- `list_orders(customer_id=None, status=None, payment_status=None) -> List[OrderInDB]` -- `get_order(order_id) -> OrderInDB` — 404 if not found -- `create_order(data: OrderCreate) -> OrderInDB` — auto-generate order_number if not set -- `update_order(order_id, data: OrderUpdate) -> OrderInDB` -- `delete_order(order_id) -> None` - -### 3. Add to `backend/crm/router.py` -Prefix `/api/crm/orders`: -- `GET /` — list_orders (query: customer_id, status, payment_status) -- `GET /{order_id}` — get_order -- `POST /` — create_order -- `PUT /{order_id}` — update_order -- `DELETE /{order_id}` — delete_order - -Register in `backend/main.py`. - -## Notes -- subtotal and total_price are stored as-is (calculated by frontend before POST/PUT). Backend does not recalculate. -- Order number generation doesn't need to be atomic/perfect — just a best-effort sequential label. diff --git a/.claude/crm-step-04.md b/.claude/crm-step-04.md deleted file mode 100644 index 8a59113..0000000 --- a/.claude/crm-step-04.md +++ /dev/null @@ -1,96 +0,0 @@ -# CRM Step 04 — Backend: Comms Log + Media (SQLite) - -## Context -Read `.claude/crm-build-plan.md` for full schema, conventions, and IMPORTANT NOTES. -Steps 01–03 must be complete. - -## Task -Add `crm_comms_log` and `crm_media` tables to the existing SQLite DB, plus CRUD endpoints. - -## What to build - -### 1. Add tables to `backend/mqtt/database.py` -Inside `init_db()`, add these CREATE TABLE IF NOT EXISTS statements alongside existing tables: - -```sql -CREATE TABLE IF NOT EXISTS crm_comms_log ( - id TEXT PRIMARY KEY, - customer_id TEXT NOT NULL, - type TEXT NOT NULL, - direction TEXT NOT NULL, - subject TEXT, - body TEXT, - attachments TEXT DEFAULT '[]', - ext_message_id TEXT, - logged_by TEXT, - occurred_at TEXT NOT NULL, - created_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS crm_media ( - id TEXT PRIMARY KEY, - customer_id TEXT, - order_id TEXT, - filename TEXT NOT NULL, - nextcloud_path TEXT NOT NULL, - mime_type TEXT, - direction TEXT, - tags TEXT DEFAULT '[]', - uploaded_by TEXT, - created_at TEXT NOT NULL -); -``` - -### 2. Add to `backend/crm/models.py` - -**Comms:** -- `CommType` enum — email, whatsapp, call, sms, note, in_person -- `CommDirection` enum — inbound, outbound, internal -- `CommAttachment` — filename (str), nextcloud_path (str) -- `CommCreate` — customer_id, type (CommType), direction (CommDirection), subject (Optional[str]), body (Optional[str]), attachments (List[CommAttachment] default []), ext_message_id (Optional[str]), logged_by (Optional[str]), occurred_at (str ISO — default to now if not provided) -- `CommUpdate` — subject, body, occurred_at all Optional -- `CommInDB` — all fields + id, created_at -- `CommListResponse` — entries: List[CommInDB], total: int - -**Media:** -- `MediaDirection` enum — received, sent, internal -- `MediaCreate` — customer_id (Optional[str]), order_id (Optional[str]), filename, nextcloud_path, mime_type (Optional), direction (MediaDirection optional), tags (List[str] default []), uploaded_by (Optional[str]) -- `MediaInDB` — all fields + id, created_at -- `MediaListResponse` — items: List[MediaInDB], total: int - -### 3. Add to `backend/crm/service.py` -Import `from mqtt import database as mqtt_db` for aiosqlite access. - -**Comms functions (all async):** -- `list_comms(customer_id, type=None, direction=None, limit=100) -> List[CommInDB]` - — SELECT ... WHERE customer_id=? ORDER BY occurred_at DESC -- `get_comm(comm_id) -> CommInDB` — 404 if not found -- `create_comm(data: CommCreate) -> CommInDB` — uuid id, created_at now, store attachments as JSON string -- `update_comm(comm_id, data: CommUpdate) -> CommInDB` -- `delete_comm(comm_id) -> None` - -**Media functions (all async):** -- `list_media(customer_id=None, order_id=None) -> List[MediaInDB]` -- `create_media(data: MediaCreate) -> MediaInDB` -- `delete_media(media_id) -> None` - -Parse `attachments` and `tags` JSON strings back to lists when returning models. - -### 4. Add to `backend/crm/router.py` -Prefix `/api/crm/comms`: -- `GET /` — list_comms (query: customer_id required, type, direction) -- `POST /` — create_comm -- `PUT /{comm_id}` — update_comm -- `DELETE /{comm_id}` — delete_comm - -Prefix `/api/crm/media`: -- `GET /` — list_media (query: customer_id or order_id) -- `POST /` — create_media (metadata only — no file upload here, that's Step 9) -- `DELETE /{media_id}` — delete_media - -Register both in `backend/main.py`. - -## Notes -- Use `mqtt_db.db` — it is an aiosqlite connection, use `async with mqtt_db.db.execute(...)` pattern -- Look at `backend/mqtt/database.py` for exact aiosqlite usage pattern -- attachments and tags are stored as JSON strings in SQLite, deserialized to lists in the Pydantic model diff --git a/.claude/crm-step-05.md b/.claude/crm-step-05.md deleted file mode 100644 index 32ab72f..0000000 --- a/.claude/crm-step-05.md +++ /dev/null @@ -1,55 +0,0 @@ -# CRM Step 05 — Frontend: Products Module - -## Context -Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES. -Backend Steps 01–04 must be complete and running. - -## Task -Build the Products section of the CRM frontend. - -## Files to create - -### `frontend/src/crm/products/ProductList.jsx` -- Fetch `GET /api/crm/products` with auth headers -- Show a table/list: Name, SKU, Category, Price, Stock (available), Active badge -- Search input (client-side filter on name/sku) -- Filter dropdown for category -- "New Product" button → navigate to `/crm/products/new` -- Row click → navigate to `/crm/products/:id` - -### `frontend/src/crm/products/ProductForm.jsx` -Used for both create and edit. Receives `productId` prop (null = create mode). -Fields: -- name (required), sku, category (dropdown from enum), description (textarea) -- price (number), currency (default EUR) -- Costs section (collapsible): pcb, components, enclosure, labor_hours, labor_rate, shipping_in — show computed total -- Stock section: on_hand, reserved — show available = on_hand - reserved (readonly) -- nextcloud_folder, linked_device_type, active (toggle) -- Save / Cancel buttons -- In edit mode: show Delete button with confirmation - -On save: POST `/api/crm/products` or PUT `/api/crm/products/:id` -On delete: DELETE `/api/crm/products/:id` then navigate back to list - -### `frontend/src/crm/products/index.js` -Export both components. - -## Routing -In `frontend/src/App.jsx` add: -```jsx -} /> -} /> -} /> -``` - -## Sidebar -In `frontend/src/layout/Sidebar.jsx` add a "CRM" section with: -- Products → `/crm/products` -(Customers and Orders will be added in later steps) - -## Notes -- Use existing UI patterns: SectionCard wrapper, inline styles for layout grid -- Follow the same auth header pattern as other frontend modules (getAuthHeaders from `../api/auth` or equivalent) -- Currency is always EUR for now — no need for a selector -- Computed costs total = pcb + components + enclosure + (labor_hours * labor_rate) + shipping_in, shown live as user types -- Category values: controller, striker, clock, part, repair_service — display as human-readable labels diff --git a/.claude/crm-step-06.md b/.claude/crm-step-06.md deleted file mode 100644 index f8eba29..0000000 --- a/.claude/crm-step-06.md +++ /dev/null @@ -1,84 +0,0 @@ -# CRM Step 06 — Frontend: Customers List + Detail Page - -## Context -Read `.claude/crm-build-plan.md` for full context, data models, and IMPORTANT NOTES. -Backend Steps 01–04 and Frontend Step 05 must be complete. - -## Task -Build the Customers section — the core of the CRM. - -## Files to create - -### `frontend/src/crm/customers/CustomerList.jsx` -- Fetch `GET /api/crm/customers` (query: search, tag) -- Show cards or table rows: Name, Organization, Location, Tags, primary contact -- Search input → query param `search` -- "New Customer" button → `/crm/customers/new` -- Row/card click → `/crm/customers/:id` - -### `frontend/src/crm/customers/CustomerForm.jsx` -Create/edit form. Receives `customerId` prop (null = create). - -**Sections:** -1. **Basic Info** — name, organization, language, tags (pill input), nextcloud_folder -2. **Location** — city, country, region -3. **Contacts** — dynamic list of `{ type, label, value, primary }` entries. Add/remove rows. Radio to set primary per type group. -4. **Notes** — dynamic list of `{ text, by, at }`. Add new note button. Existing notes shown as read-only with author/date. `by` auto-filled from current user name. -5. **Owned Items** — dynamic list with type selector: - - `console_device`: device_id text input + label - - `product`: product selector (fetch `/api/crm/products` for dropdown) + quantity + serial_numbers (comma-separated input) - - `freetext`: description + serial_number + notes - Add/remove rows. -6. **Linked App Accounts** — list of Firebase UIDs (simple text inputs, add/remove). Label: "Linked App User IDs" - -Save: POST or PUT. Delete with confirmation. - -### `frontend/src/crm/customers/CustomerDetail.jsx` -The main customer page. Fetches customer by ID. Tab layout: - -**Tab 1: Overview** -- Show all info from CustomerForm fields in read-only view -- "Edit" button → opens CustomerForm in a modal or navigates to edit route - -**Tab 2: Orders** -- Fetch `GET /api/crm/orders?customer_id=:id` -- List orders: order_number, status badge, total_price, date -- "New Order" button → navigate to `/crm/orders/new?customer_id=:id` -- Row click → `/crm/orders/:id` - -**Tab 3: Comms** -- Fetch `GET /api/crm/comms?customer_id=:id` -- Timeline view sorted by occurred_at descending -- Each entry shows: type icon, direction indicator, subject/body preview, date -- "Log Entry" button → inline form to create a new comms entry (type, direction, subject, body, occurred_at) - -**Tab 4: Media** -- Fetch `GET /api/crm/media?customer_id=:id` -- Grid of files: filename, direction badge (Received/Sent/Internal), date -- "Add Media Record" button → form with filename, nextcloud_path, direction, tags (manual entry for now — Nextcloud integration comes in Step 9) - -**Tab 5: Devices** (read-only summary) -- Display `owned_items` grouped by type -- For console_device items: link to `/devices/:device_id` in a new tab - -### `frontend/src/crm/customers/index.js` -Export all components. - -## Routing in `frontend/src/App.jsx` -```jsx -} /> -} /> -} /> -} /> -``` - -## Sidebar update -Add to CRM section: -- Customers → `/crm/customers` - -## Notes -- ALL hooks in CustomerDetail must be before any early returns (loading/error states) -- Tag input: comma or enter to add, click pill to remove -- Contact type icons: use simple text labels or emoji (📧 📞 💬) — keep it simple -- Comms type icons: simple colored badges per type (email=blue, whatsapp=green, call=yellow, note=grey) -- No file upload UI yet in Media tab — just nextcloud_path text field for now (Step 9 adds real upload) diff --git a/.claude/crm-step-07.md b/.claude/crm-step-07.md deleted file mode 100644 index 7beafe1..0000000 --- a/.claude/crm-step-07.md +++ /dev/null @@ -1,71 +0,0 @@ -# CRM Step 07 — Frontend: Orders Module - -## Context -Read `.claude/crm-build-plan.md` for full context, data models, and IMPORTANT NOTES. -Steps 01–06 must be complete. - -## Task -Build the Orders section. - -## Files to create - -### `frontend/src/crm/orders/OrderList.jsx` -- Fetch `GET /api/crm/orders` (query: status, payment_status) -- Table: Order #, Customer name (resolve from customer_id via separate fetch or denormalize), Status badge, Total, Payment status, Date -- Filter dropdowns: Status, Payment Status -- "New Order" button → `/crm/orders/new` -- Row click → `/crm/orders/:id` - -### `frontend/src/crm/orders/OrderForm.jsx` -Create/edit. Receives `orderId` prop and optional `customerId` from query param. - -**Sections:** -1. **Customer** — searchable dropdown (fetch `/api/crm/customers`). Shows name + organization. -2. **Order Info** — order_number (auto, editable), status (dropdown), currency -3. **Items** — dynamic list. Each item: - - type selector: console_device | product | freetext - - product: dropdown from `/api/crm/products` (auto-fills product_name, unit_price) - - console_device: text input for device_id + label - - freetext: description text input - - quantity (number), unit_price (number), serial_numbers (comma-separated) - - Remove row button - - Add Item button -4. **Pricing** — show computed subtotal (sum of qty * unit_price). Discount: type toggle (% or fixed) + value input + reason. Show computed total = subtotal - discount. These values are sent to backend as-is. -5. **Payment** — payment_status dropdown, invoice_path (nextcloud path text input) -6. **Shipping** — method, carrier, tracking_number, destination, shipped_at (date), delivered_at (date) -7. **Notes** — textarea - -Save → POST or PUT. Delete with confirmation. - -### `frontend/src/crm/orders/OrderDetail.jsx` -Read-only view of a single order. -- Header: order number, status badge, customer name (link to customer) -- Items table: product/description, qty, unit price, line total -- Pricing summary: subtotal, discount, total -- Shipping card: all shipping fields -- Payment card: status, invoice path (if set, show as link) -- Notes -- Edit button → OrderForm -- Back to customer button - -### `frontend/src/crm/orders/index.js` -Export all components. - -## Routing in `frontend/src/App.jsx` -```jsx -} /> -} /> -} /> -} /> -``` - -## Sidebar update -Add to CRM section: -- Orders → `/crm/orders` - -## Notes -- Status badge colors: draft=grey, confirmed=blue, in_production=orange, shipped=purple, delivered=green, cancelled=red -- Payment status: pending=yellow, partial=orange, paid=green -- Discount calculation: if type=percentage → total = subtotal * (1 - value/100). if type=fixed → total = subtotal - value -- When a product is selected from dropdown in item row, auto-fill unit_price from product.price (user can override) -- Order list needs customer names — either fetch all customers once and build a map, or add customer_name as a denormalized field when creating/updating orders (simpler: fetch customer list once) diff --git a/.claude/crm-step-08.md b/.claude/crm-step-08.md deleted file mode 100644 index e84e4d3..0000000 --- a/.claude/crm-step-08.md +++ /dev/null @@ -1,53 +0,0 @@ -# CRM Step 08 — Frontend: Comms Log + Media (Manual Entry Polish) - -## Context -Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES. -Steps 01–07 must be complete. - -## Task -Two things: -1. A standalone **Inbox** page — unified comms view across all customers -2. Polish the Comms and Media tabs on CustomerDetail (from Step 06) — improve the UI - -## Files to create/update - -### `frontend/src/crm/inbox/InboxPage.jsx` -- Fetch `GET /api/crm/comms?customer_id=ALL` — wait, this doesn't exist yet. - → Instead, fetch all customers, then fetch comms for each? No — too many requests. - → Add a new backend endpoint first (see below). -- Show a unified timeline of all comms entries across all customers, sorted by occurred_at desc -- Each entry shows: customer name (link), type badge, direction, subject/body preview, date -- Filter by type (email/whatsapp/call/note/etc), direction, customer (dropdown) -- Pagination or virtual scroll (limit to last 100 entries) - -### Backend addition needed — add to `backend/crm/router.py` and `service.py`: -`GET /api/crm/comms/all` — fetch all comms (no customer_id filter), sorted by occurred_at DESC, limit 200. -`list_all_comms(type=None, direction=None, limit=200) -> List[CommInDB]` in service. - -### Comms tab improvements (update CustomerDetail.jsx) -- Full timeline view with visual connector line between entries -- Each entry is expandable — click to see full body -- Entry form as an inline slide-down panel (not a modal) -- Form fields: type (icons + labels), direction, subject, body (textarea), occurred_at (datetime-local input, defaults to now), attachments (add nextcloud_path manually for now) -- After save, refresh comms list - -### Media tab improvements (update CustomerDetail.jsx) -- Group media by direction: "Received" section, "Sent" section, "Internal" section -- Show filename, tags as pills, date -- "Add Media" inline form: filename (required), nextcloud_path (required), direction (dropdown), tags (pill input) -- Delete button per item with confirmation - -## Routing in `frontend/src/App.jsx` -```jsx -} /> -``` - -## Sidebar update -Add to CRM section (at top of CRM group): -- Inbox → `/crm/inbox` - -## Notes -- This step is mostly UI polish + the inbox page. No new integrations. -- The inbox page is the "central comms view" from the original requirements — all messages in one place -- Keep the comms entry form simple: only show attachment fields if user clicks "Add attachment" -- Type badges: email=blue, whatsapp=green, call=amber, sms=teal, note=grey, in_person=purple diff --git a/.claude/crm-step-09.md b/.claude/crm-step-09.md deleted file mode 100644 index d1ddd01..0000000 --- a/.claude/crm-step-09.md +++ /dev/null @@ -1,92 +0,0 @@ -# CRM Step 09 — Integration: Nextcloud WebDAV - -## Context -Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES. -Steps 01–08 must be complete. - -## Task -Connect the console to Nextcloud via WebDAV so that: -1. Files in a customer's Nextcloud folder are listed in the Media tab automatically -2. Uploading a file from the console sends it to Nextcloud -3. Files can be downloaded/previewed via a backend proxy - -## Backend changes - -### 1. Add Nextcloud settings to `backend/config.py` -```python -nextcloud_url: str = "https://nextcloud.bonamin.gr" # e.g. https://cloud.example.com -nextcloud_email: str = "bellsystems.gr@gmail.com" -nextcloud_username: str = "bellsystems-console" -nextcloud_password: str = "ydE916VdaQdbP2CQGhD!" -nextcloud_app_password: str = "rtLCp-NCy3y-gNZdg-38MtN-r8D2N" -nextcloud_base_path: str = "BellSystems" # root folder inside Nextcloud -``` - -### 2. Create `backend/crm/nextcloud.py` -WebDAV client using `httpx` (already available). Functions: - -```python -async def list_folder(nextcloud_path: str) -> List[dict]: - """ - PROPFIND request to Nextcloud WebDAV. - Returns list of {filename, path, mime_type, size, last_modified, is_dir} - Parse the XML response (use xml.etree.ElementTree). - URL: {nextcloud_url}/remote.php/dav/files/{username}/{nextcloud_base_path}/{nextcloud_path} - """ - -async def upload_file(nextcloud_path: str, filename: str, content: bytes, mime_type: str) -> str: - """ - PUT request to upload file. - Returns the full nextcloud_path of the uploaded file. - """ - -async def download_file(nextcloud_path: str) -> tuple[bytes, str]: - """ - GET request. Returns (content_bytes, mime_type). - """ - -async def delete_file(nextcloud_path: str) -> None: - """ - DELETE request. - """ -``` - -Use HTTP Basic Auth with nextcloud_username/nextcloud_password. -If nextcloud_url is empty string, raise HTTPException 503 "Nextcloud not configured". - -### 3. Add to `backend/crm/router.py` - -**Media/Nextcloud endpoints:** - -`GET /api/crm/nextcloud/browse?path=05_Customers/FOLDER` -→ calls `list_folder(path)`, returns file list - -`GET /api/crm/nextcloud/file?path=05_Customers/FOLDER/photo.jpg` -→ calls `download_file(path)`, returns `Response(content=bytes, media_type=mime_type)` -→ This is the proxy endpoint — frontend uses this to display images - -`POST /api/crm/nextcloud/upload` -→ accepts `UploadFile` + form field `nextcloud_path` (destination folder) -→ calls `upload_file(...)`, then calls `create_media(...)` to save the metadata record -→ returns the created `MediaInDB` - -`DELETE /api/crm/nextcloud/file?path=...` -→ calls `delete_file(path)`, also deletes the matching `crm_media` record if found - -## Frontend changes - -### Update Media tab in `CustomerDetail.jsx` -- On load: if `customer.nextcloud_folder` is set, fetch `GET /api/crm/nextcloud/browse?path={customer.nextcloud_folder}` and merge results with existing `crm_media` records. Show files from both sources — deduplicate by nextcloud_path. -- Image files: render as `` via the proxy endpoint -- Other files: show as a download link hitting the same proxy endpoint -- Upload button: file picker → POST to `/api/crm/nextcloud/upload` with file + destination path (default to customer's Sent Media subfolder) -- Show upload progress indicator - -### Update Media tab in `CustomerDetail.jsx` — subfolder selector -When uploading, let user choose subfolder: "Sent Media" | "Received Media" | "Internal" (maps to direction field too) - -## Notes -- `httpx` is likely already in requirements. If not, add it: `httpx>=0.27.0` -- PROPFIND response is XML (DAV namespace). Parse `D:response` elements, extract `D:href` and `D:prop` children. -- The proxy approach means the VPS never stores files — it just streams them through from Nextcloud -- nextcloud_base_path in config allows the root to be `BellSystems` so paths in DB are relative to that root diff --git a/.claude/crm-step-10.md b/.claude/crm-step-10.md deleted file mode 100644 index 4498624..0000000 --- a/.claude/crm-step-10.md +++ /dev/null @@ -1,102 +0,0 @@ -# CRM Step 10 — Integration: IMAP/SMTP Email - -## Context -Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES. -Steps 01–09 must be complete. - -## Task -Integrate the company email mailbox so that: -1. Emails from/to a customer's email addresses appear in their Comms tab -2. New emails can be composed and sent from the console -3. A background sync runs periodically to pull new emails - -## Backend changes - -### 1. Add email settings to `backend/config.py` -```python -imap_host: str = "" -imap_port: int = 993 -imap_username: str = "" -imap_password: str = "" -imap_use_ssl: bool = True -smtp_host: str = "" -smtp_port: int = 587 -smtp_username: str = "" -smtp_password: str = "" -smtp_use_tls: bool = True -email_sync_interval_minutes: int = 15 -``` - -### 2. Create `backend/crm/email_sync.py` -Using standard library `imaplib` and `email` (no new deps). - -```python -async def sync_emails(): - """ - Connect to IMAP. Search UNSEEN or since last sync date. - For each email: - - Parse from/to/subject/body (text/plain preferred, fallback to stripped HTML) - - Check if from-address or to-address matches any customer contact (search crm_customers) - - If match found: create crm_comms_log entry with type=email, ext_message_id=message-id header - - Skip if ext_message_id already exists in crm_comms_log (dedup) - Store last sync time in a simple SQLite table crm_sync_state: - CREATE TABLE IF NOT EXISTS crm_sync_state (key TEXT PRIMARY KEY, value TEXT) - """ - -async def send_email(to: str, subject: str, body: str, cc: List[str] = []) -> str: - """ - Send email via SMTP. Returns message-id. - After sending, create a crm_comms_log entry: type=email, direction=outbound. - """ -``` - -### 3. Add SQLite table to `backend/mqtt/database.py` -```sql -CREATE TABLE IF NOT EXISTS crm_sync_state ( - key TEXT PRIMARY KEY, - value TEXT -); -``` - -### 4. Add email endpoints to `backend/crm/router.py` - -`POST /api/crm/email/send` -Body: `{ customer_id, to, subject, body, cc (optional) }` -→ calls `send_email(...)`, links to customer in comms_log - -`POST /api/crm/email/sync` -→ manually trigger `sync_emails()` (for testing / on-demand) -→ returns count of new emails found - -### 5. Add background sync to `backend/main.py` -In the `startup` event, add a periodic task: -```python -async def email_sync_loop(): - while True: - await asyncio.sleep(settings.email_sync_interval_minutes * 60) - try: - from crm.email_sync import sync_emails - await sync_emails() - except Exception as e: - print(f"[EMAIL SYNC] Error: {e}") - -asyncio.create_task(email_sync_loop()) -``` -Only start if `settings.imap_host` is set (non-empty). - -## Frontend changes - -### Update Comms tab in `CustomerDetail.jsx` -- Email entries show: from/to, subject, body (truncated with expand) -- "Compose Email" button → modal with: to (pre-filled from customer primary email), subject, body (textarea), CC -- On send: POST `/api/crm/email/send`, add new entry to comms list - -### Update `InboxPage.jsx` -- Add "Sync Now" button → POST `/api/crm/email/sync`, show result count toast - -## Notes -- `imaplib` is synchronous — wrap in `asyncio.run_in_executor(None, sync_fn)` for the async context -- For HTML emails: strip tags with a simple regex or `html.parser` — no need for an HTML renderer -- Email body matching: compare email From/To headers against ALL customer contacts where type=email -- Don't sync attachments yet — just text content. Attachment handling can be a future step. -- If imap_host is empty string, the sync loop doesn't start and the send endpoint returns 503 diff --git a/.claude/crm-step-11.md b/.claude/crm-step-11.md deleted file mode 100644 index d2a3da7..0000000 --- a/.claude/crm-step-11.md +++ /dev/null @@ -1,81 +0,0 @@ -# CRM Step 11 — Integration: WhatsApp Business API - -## Context -Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES. -Steps 01–10 must be complete. - -## Prerequisites (manual setup required before this step) -- A Meta Business account with WhatsApp Business API enabled -- A dedicated phone number registered to WhatsApp Business API (NOT a personal number) -- A Meta App with webhook configured to point to: `https://yourdomain.com/api/crm/whatsapp/webhook` -- The following values ready: `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_VERIFY_TOKEN` - -## Task -Receive inbound WhatsApp messages via webhook and send outbound messages, all logged to crm_comms_log. - -## Backend changes - -### 1. Add to `backend/config.py` -```python -whatsapp_phone_number_id: str = "" -whatsapp_access_token: str = "" -whatsapp_verify_token: str = "change-me" # you set this in Meta webhook config -``` - -### 2. Create `backend/crm/whatsapp.py` -```python -async def send_whatsapp(to_phone: str, message: str) -> str: - """ - POST to https://graph.facebook.com/v19.0/{phone_number_id}/messages - Headers: Authorization: Bearer {access_token} - Body: { messaging_product: "whatsapp", to: to_phone, type: "text", text: { body: message } } - Returns the wamid (WhatsApp message ID). - """ -``` - -### 3. Add webhook + send endpoints to `backend/crm/router.py` - -`GET /api/crm/whatsapp/webhook` -— Meta webhook verification. Check `hub.verify_token` == settings.whatsapp_verify_token. -Return `hub.challenge` if valid, else 403. -**No auth required on this endpoint.** - -`POST /api/crm/whatsapp/webhook` -— Receive inbound message events from Meta. -**No auth required on this endpoint.** -Parse payload: -``` -entry[0].changes[0].value.messages[0] - .from → sender phone number (e.g. "306974015758") - .id → wamid - .type → "text" - .text.body → message content - .timestamp → unix timestamp -``` -For each message: -1. Look up customer by phone number in crm_customers contacts (where type=phone or whatsapp) -2. If found: create crm_comms_log entry (type=whatsapp, direction=inbound, ext_message_id=wamid) -3. If not found: still log it but with customer_id="unknown:{phone}" - -`POST /api/crm/whatsapp/send` -Body: `{ customer_id, to_phone, message }` -Requires auth. -→ calls `send_whatsapp(...)`, creates outbound comms_log entry - -## Frontend changes - -### Update Comms tab in `CustomerDetail.jsx` -- WhatsApp entries: green background, WhatsApp icon -- "Send WhatsApp" button → modal with: to_phone (pre-filled from customer's whatsapp/phone contacts), message textarea -- On send: POST `/api/crm/whatsapp/send` - -### Update `InboxPage.jsx` -- WhatsApp entries are already included (from crm_comms_log) -- Add type filter option for "WhatsApp" - -## Notes -- Phone number format: Meta sends numbers without `+` (e.g. "306974015758"). Normalize when matching against customer contacts (strip `+` and spaces). -- Webhook payload can contain multiple entries and messages — iterate and handle each -- Rate limits: Meta free tier = 1000 conversations/month (a conversation = 24h window with a customer). More than enough. -- If whatsapp_phone_number_id is empty, the send endpoint returns 503. The webhook endpoint must always be available (it's a public endpoint). -- Media messages (images, docs): in this step, just log "Media message received" as body text. Full media download is a future enhancement. diff --git a/.claude/crm-step-12.md b/.claude/crm-step-12.md deleted file mode 100644 index 61b2482..0000000 --- a/.claude/crm-step-12.md +++ /dev/null @@ -1,97 +0,0 @@ -# CRM Step 12 — Integration: FreePBX AMI Call Logging - -## Context -Read `.claude/crm-build-plan.md` for full context and IMPORTANT NOTES. -Steps 01–11 must be complete. - -## Prerequisites (manual setup before this step) -- FreePBX server with AMI (Asterisk Manager Interface) enabled -- An AMI user created in FreePBX: Admin → Asterisk Manager Users - - Username + password (set these in config) - - Permissions needed: read = "call,cdr" (minimum) -- Network access from VPS to FreePBX AMI port (default: 5038) -- Values ready: `AMI_HOST`, `AMI_PORT` (5038), `AMI_USERNAME`, `AMI_PASSWORD` - -## Task -Connect to FreePBX AMI over TCP, listen for call events, and auto-log them to crm_comms_log matched against customer phone numbers. - -## Backend changes - -### 1. Add to `backend/config.py` -```python -ami_host: str = "" -ami_port: int = 5038 -ami_username: str = "" -ami_password: str = "" -``` - -### 2. Create `backend/crm/ami_listener.py` -AMI uses a plain TCP socket with a text protocol (key: value\r\n pairs, events separated by \r\n\r\n). - -```python -import asyncio -from config import settings -from mqtt import database as mqtt_db - -async def ami_connect_and_listen(): - """ - 1. Open TCP connection to ami_host:ami_port - 2. Read the banner line - 3. Send login action: - Action: Login\r\n - Username: {ami_username}\r\n - Secret: {ami_password}\r\n\r\n - 4. Read response — check for "Response: Success" - 5. Loop reading events. Parse each event block into a dict. - 6. Handle Event: Hangup: - - CallerID: the phone number (field: CallerIDNum) - - Duration: call duration seconds (field: Duration, may not always be present) - - Channel direction: inbound if DestChannel starts with "PJSIP/" or "SIP/", - outbound if Channel starts with "PJSIP/" or "SIP/" - - Normalize CallerIDNum: strip leading + and spaces - - Look up customer by normalized phone - - Create crm_comms_log entry: type=call, direction=inbound|outbound, - body=f"Call duration: {duration}s", ext_message_id=Uniqueid field - 7. On disconnect: wait 30s, reconnect. Infinite retry loop. - """ - -async def start_ami_listener(): - """Entry point — only starts if ami_host is set.""" - if not settings.ami_host: - return - asyncio.create_task(ami_connect_and_listen()) -``` - -### 3. Add to `backend/main.py` startup -```python -from crm.ami_listener import start_ami_listener -# in startup(): -await start_ami_listener() -``` - -### 4. Add manual log endpoint to `backend/crm/router.py` -`POST /api/crm/calls/log` -Body: `{ customer_id, direction, duration_seconds, notes, occurred_at }` -Requires auth. -→ create crm_comms_log entry (type=call) manually -→ useful if auto-logging misses a call or for logging calls made outside the office - -## Frontend changes - -### Update Comms tab in `CustomerDetail.jsx` -- Call entries: amber/yellow color, phone icon -- Show duration if available (parse from body) -- "Log Call" button → quick modal with: direction (inbound/outbound), duration (minutes + seconds), notes, occurred_at -- On save: POST `/api/crm/calls/log` - -### Update `InboxPage.jsx` -- Add "Call" to type filter options -- Call entries show customer name, direction arrow, duration - -## Notes -- AMI protocol reference: each event/response is a block of `Key: Value` lines terminated by `\r\n\r\n` -- The `Hangup` event fires at end of call and includes Duration in seconds -- CallerIDNum for inbound calls is the caller's number. For outbound it's typically the extension — may need to use `DestCallerIDNum` instead. Test against your FreePBX setup. -- Phone matching uses the same normalization as WhatsApp step (strip `+`, spaces, leading zeros if needed) -- If AMI connection drops (FreePBX restart, network blip), the reconnect loop handles it silently -- This gives you: auto-logged inbound calls matched to customers, duration recorded, plus a manual log option for anything missed diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 9495fd2..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(npm create:*)", - "Bash(npm install:*)", - "Bash(npm run build:*)", - "Bash(python -c:*)", - "Bash(npx vite build:*)", - "Bash(wc:*)", - "Bash(ls:*)", - "Bash(node -c:*)", - "Bash(npm run lint:*)", - "Bash(python:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index b166237..9c0f49b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ Thumbs.db .MAIN-APP-REFERENCE/ -.project-vesper-plan.md \ No newline at end of file +.project-vesper-plan.md + +# claude +.claude/ \ No newline at end of file