From c62188fda68e6fd23cd6e7464ad1a429fdddbfb1 Mon Sep 17 00:00:00 2001 From: bonamin Date: Sat, 7 Mar 2026 11:32:18 +0200 Subject: [PATCH] update: Major Overhaul to all subsystems --- .claude/backend-mqtt-alerts-prompt.md | 153 + .claude/crm-build-plan.md | 243 ++ .claude/crm-step-01.md | 49 + .claude/crm-step-02.md | 61 + .claude/crm-step-03.md | 60 + .claude/crm-step-04.md | 96 + .claude/crm-step-05.md | 55 + .claude/crm-step-06.md | 84 + .claude/crm-step-07.md | 71 + .claude/crm-step-08.md | 53 + .claude/crm-step-09.md | 92 + .claude/crm-step-10.md | 102 + .claude/crm-step-11.md | 81 + .claude/crm-step-12.md | 97 + .env.example | 9 + backend/Dockerfile | 10 + backend/auth/models.py | 142 +- backend/config.py | 35 +- backend/crm/__init__.py | 0 backend/crm/comms_router.py | 417 +++ backend/crm/customers_router.py | 71 + backend/crm/email_sync.py | 837 +++++ backend/crm/mail_accounts.py | 104 + backend/crm/media_router.py | 35 + backend/crm/models.py | 353 ++ backend/crm/nextcloud.py | 314 ++ backend/crm/nextcloud_router.py | 305 ++ backend/crm/orders_router.py | 57 + backend/crm/quotation_models.py | 141 + backend/crm/quotations_router.py | 101 + backend/crm/quotations_service.py | 494 +++ backend/crm/router.py | 93 + backend/crm/service.py | 619 ++++ backend/devices/router.py | 12 + backend/firmware/models.py | 24 +- backend/firmware/router.py | 16 +- backend/firmware/service.py | 65 +- backend/main.py | 42 + backend/manufacturing/models.py | 28 +- backend/mqtt/client.py | 4 +- backend/mqtt/database.py | 189 ++ backend/mqtt/logger.py | 31 + backend/mqtt/models.py | 12 + backend/requirements.txt | 5 +- .../5d3873c0-1600-4c3b-a4e5-595e8fe19c8c.png | Bin 0 -> 21894 bytes backend/templates/linktree.png | Bin 0 -> 20630 bytes backend/templates/logo.png | Bin 0 -> 14520 bytes backend/templates/quotation.html | 708 ++++ backend/utils/nvs_generator.py | 2 +- frontend/index.html | 2 +- frontend/public/favicon-96x96.png | Bin 0 -> 7035 bytes frontend/public/favicon.svg | 1 + frontend/src/App.jsx | 31 + frontend/src/assets/comms/call.svg | 25 + frontend/src/assets/comms/email.svg | 8 + frontend/src/assets/comms/inbound.svg | 4 + frontend/src/assets/comms/inperson.svg | 17 + frontend/src/assets/comms/internal.svg | 8 + frontend/src/assets/comms/mail.svg | 2 + frontend/src/assets/comms/note.svg | 2 + frontend/src/assets/comms/outbound.svg | 4 + frontend/src/assets/comms/sms.svg | 2 + frontend/src/assets/comms/whatsapp.svg | 12 + frontend/src/auth/AuthContext.jsx | 34 +- frontend/src/crm/components/CommIcons.jsx | 141 + .../src/crm/components/ComposeEmailModal.jsx | 928 ++++++ frontend/src/crm/components/MailViewModal.jsx | 669 ++++ frontend/src/crm/customers/CustomerDetail.jsx | 2883 +++++++++++++++++ frontend/src/crm/customers/CustomerForm.jsx | 579 ++++ frontend/src/crm/customers/CustomerList.jsx | 174 + frontend/src/crm/customers/index.js | 3 + frontend/src/crm/inbox/CommsPage.jsx | 466 +++ frontend/src/crm/inbox/InboxPage.jsx | 327 ++ frontend/src/crm/mail/MailPage.jsx | 838 +++++ frontend/src/crm/orders/OrderDetail.jsx | 293 ++ frontend/src/crm/orders/OrderForm.jsx | 662 ++++ frontend/src/crm/orders/OrderList.jsx | 207 ++ frontend/src/crm/orders/index.js | 3 + frontend/src/crm/products/ProductForm.jsx | 635 ++++ frontend/src/crm/products/ProductList.jsx | 215 ++ frontend/src/crm/products/index.js | 2 + frontend/src/crm/quotations/QuotationForm.jsx | 1070 ++++++ frontend/src/crm/quotations/QuotationList.jsx | 438 +++ frontend/src/crm/quotations/index.js | 2 + frontend/src/developer/ApiReferencePage.jsx | 1490 +++++++++ frontend/src/devices/DeviceDetail.jsx | 25 +- frontend/src/devices/DeviceForm.jsx | 134 +- frontend/src/equipment/NoteDetail.jsx | 63 +- frontend/src/equipment/NoteForm.jsx | 32 +- frontend/src/firmware/FirmwareManager.jsx | 384 ++- frontend/src/index.css | 138 +- frontend/src/layout/Header.jsx | 204 +- frontend/src/layout/Sidebar.jsx | 91 +- frontend/src/manufacturing/BatchCreator.jsx | 32 +- .../src/manufacturing/DeviceInventory.jsx | 25 +- .../manufacturing/DeviceInventoryDetail.jsx | 44 +- .../src/manufacturing/ProvisioningWizard.jsx | 14 +- frontend/src/melodies/MelodyComposer.jsx | 15 +- frontend/src/melodies/MelodyDetail.jsx | 65 +- frontend/src/melodies/MelodyForm.jsx | 78 +- frontend/src/melodies/MelodySettings.jsx | 29 +- .../src/melodies/archetypes/ArchetypeForm.jsx | 22 +- frontend/src/settings/StaffDetail.jsx | 320 +- frontend/src/settings/StaffForm.jsx | 673 ++-- frontend/src/users/UserDetail.jsx | 91 +- frontend/src/users/UserForm.jsx | 113 +- nginx/nginx.conf | 2 + 107 files changed, 20414 insertions(+), 929 deletions(-) create mode 100644 .claude/backend-mqtt-alerts-prompt.md create mode 100644 .claude/crm-build-plan.md create mode 100644 .claude/crm-step-01.md create mode 100644 .claude/crm-step-02.md create mode 100644 .claude/crm-step-03.md create mode 100644 .claude/crm-step-04.md create mode 100644 .claude/crm-step-05.md create mode 100644 .claude/crm-step-06.md create mode 100644 .claude/crm-step-07.md create mode 100644 .claude/crm-step-08.md create mode 100644 .claude/crm-step-09.md create mode 100644 .claude/crm-step-10.md create mode 100644 .claude/crm-step-11.md create mode 100644 .claude/crm-step-12.md create mode 100644 backend/crm/__init__.py create mode 100644 backend/crm/comms_router.py create mode 100644 backend/crm/customers_router.py create mode 100644 backend/crm/email_sync.py create mode 100644 backend/crm/mail_accounts.py create mode 100644 backend/crm/media_router.py create mode 100644 backend/crm/models.py create mode 100644 backend/crm/nextcloud.py create mode 100644 backend/crm/nextcloud_router.py create mode 100644 backend/crm/orders_router.py create mode 100644 backend/crm/quotation_models.py create mode 100644 backend/crm/quotations_router.py create mode 100644 backend/crm/quotations_service.py create mode 100644 backend/crm/router.py create mode 100644 backend/crm/service.py create mode 100644 backend/storage/product_images/5d3873c0-1600-4c3b-a4e5-595e8fe19c8c.png create mode 100644 backend/templates/linktree.png create mode 100644 backend/templates/logo.png create mode 100644 backend/templates/quotation.html create mode 100644 frontend/public/favicon-96x96.png create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/assets/comms/call.svg create mode 100644 frontend/src/assets/comms/email.svg create mode 100644 frontend/src/assets/comms/inbound.svg create mode 100644 frontend/src/assets/comms/inperson.svg create mode 100644 frontend/src/assets/comms/internal.svg create mode 100644 frontend/src/assets/comms/mail.svg create mode 100644 frontend/src/assets/comms/note.svg create mode 100644 frontend/src/assets/comms/outbound.svg create mode 100644 frontend/src/assets/comms/sms.svg create mode 100644 frontend/src/assets/comms/whatsapp.svg create mode 100644 frontend/src/crm/components/CommIcons.jsx create mode 100644 frontend/src/crm/components/ComposeEmailModal.jsx create mode 100644 frontend/src/crm/components/MailViewModal.jsx create mode 100644 frontend/src/crm/customers/CustomerDetail.jsx create mode 100644 frontend/src/crm/customers/CustomerForm.jsx create mode 100644 frontend/src/crm/customers/CustomerList.jsx create mode 100644 frontend/src/crm/customers/index.js create mode 100644 frontend/src/crm/inbox/CommsPage.jsx create mode 100644 frontend/src/crm/inbox/InboxPage.jsx create mode 100644 frontend/src/crm/mail/MailPage.jsx create mode 100644 frontend/src/crm/orders/OrderDetail.jsx create mode 100644 frontend/src/crm/orders/OrderForm.jsx create mode 100644 frontend/src/crm/orders/OrderList.jsx create mode 100644 frontend/src/crm/orders/index.js create mode 100644 frontend/src/crm/products/ProductForm.jsx create mode 100644 frontend/src/crm/products/ProductList.jsx create mode 100644 frontend/src/crm/products/index.js create mode 100644 frontend/src/crm/quotations/QuotationForm.jsx create mode 100644 frontend/src/crm/quotations/QuotationList.jsx create mode 100644 frontend/src/crm/quotations/index.js create mode 100644 frontend/src/developer/ApiReferencePage.jsx diff --git a/.claude/backend-mqtt-alerts-prompt.md b/.claude/backend-mqtt-alerts-prompt.md new file mode 100644 index 0000000..c95730f --- /dev/null +++ b/.claude/backend-mqtt-alerts-prompt.md @@ -0,0 +1,153 @@ +# 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 new file mode 100644 index 0000000..f42f7c2 --- /dev/null +++ b/.claude/crm-build-plan.md @@ -0,0 +1,243 @@ +# 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 new file mode 100644 index 0000000..96c0c15 --- /dev/null +++ b/.claude/crm-step-01.md @@ -0,0 +1,49 @@ +# 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 new file mode 100644 index 0000000..ec3a18c --- /dev/null +++ b/.claude/crm-step-02.md @@ -0,0 +1,61 @@ +# 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 new file mode 100644 index 0000000..979c21e --- /dev/null +++ b/.claude/crm-step-03.md @@ -0,0 +1,60 @@ +# 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 new file mode 100644 index 0000000..8a59113 --- /dev/null +++ b/.claude/crm-step-04.md @@ -0,0 +1,96 @@ +# 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 new file mode 100644 index 0000000..32ab72f --- /dev/null +++ b/.claude/crm-step-05.md @@ -0,0 +1,55 @@ +# 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 new file mode 100644 index 0000000..f8eba29 --- /dev/null +++ b/.claude/crm-step-06.md @@ -0,0 +1,84 @@ +# 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 new file mode 100644 index 0000000..7beafe1 --- /dev/null +++ b/.claude/crm-step-07.md @@ -0,0 +1,71 @@ +# 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 new file mode 100644 index 0000000..e84e4d3 --- /dev/null +++ b/.claude/crm-step-08.md @@ -0,0 +1,53 @@ +# 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 new file mode 100644 index 0000000..d1ddd01 --- /dev/null +++ b/.claude/crm-step-09.md @@ -0,0 +1,92 @@ +# 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 new file mode 100644 index 0000000..4498624 --- /dev/null +++ b/.claude/crm-step-10.md @@ -0,0 +1,102 @@ +# 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 new file mode 100644 index 0000000..d2a3da7 --- /dev/null +++ b/.claude/crm-step-11.md @@ -0,0 +1,81 @@ +# 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 new file mode 100644 index 0000000..61b2482 --- /dev/null +++ b/.claude/crm-step-12.md @@ -0,0 +1,97 @@ +# 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/.env.example b/.env.example index e28a76f..84b0234 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,8 @@ MQTT_BROKER_PORT=1883 MQTT_ADMIN_USERNAME=admin MQTT_ADMIN_PASSWORD=your-mqtt-admin-password MOSQUITTO_PASSWORD_FILE=/etc/mosquitto/passwd +# Must be unique per running instance (VPS vs local dev) +MQTT_CLIENT_ID=bellsystems-admin-panel # HMAC secret used to derive per-device MQTT passwords (must match firmware) MQTT_SECRET=change-me-in-production @@ -26,3 +28,10 @@ NGINX_PORT=80 SQLITE_DB_PATH=./mqtt_data.db BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies FIRMWARE_STORAGE_PATH=./storage/firmware + +# Nextcloud WebDAV +NEXTCLOUD_URL=https://cloud.example.com +NEXTCLOUD_USERNAME=service-account@example.com +NEXTCLOUD_PASSWORD=your-password-here +NEXTCLOUD_DAV_USER=admin +NEXTCLOUD_BASE_PATH=BellSystems/Console diff --git a/backend/Dockerfile b/backend/Dockerfile index 0c1a278..9f0ae20 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,15 @@ FROM python:3.11-slim +# WeasyPrint system dependencies (libpango, libcairo, etc.) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf-2.0-0 \ + libffi-dev \ + shared-mime-info \ + fonts-dejavu-core \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY requirements.txt . diff --git a/backend/auth/models.py b/backend/auth/models.py index 79a56a4..37419f1 100644 --- a/backend/auth/models.py +++ b/backend/auth/models.py @@ -10,45 +10,141 @@ class Role(str, Enum): user = "user" -class SectionPermissions(BaseModel): +class MelodiesPermissions(BaseModel): + view: bool = False + add: bool = False + delete: bool = False + safe_edit: bool = False + full_edit: bool = False + archetype_access: bool = False + settings_access: bool = False + compose_access: bool = False + + +class DevicesPermissions(BaseModel): + view: bool = False + add: bool = False + delete: bool = False + safe_edit: bool = False + edit_bells: bool = False + edit_clock: bool = False + edit_warranty: bool = False + full_edit: bool = False + control: bool = False + + +class AppUsersPermissions(BaseModel): + view: bool = False + add: bool = False + delete: bool = False + safe_edit: bool = False + full_edit: bool = False + + +class IssuesNotesPermissions(BaseModel): + view: bool = False + add: bool = False + delete: bool = False + edit: bool = False + + +class MailPermissions(BaseModel): + view: bool = False + compose: bool = False + reply: bool = False + + +class CrmPermissions(BaseModel): + activity_log: bool = False + + +class CrmCustomersPermissions(BaseModel): + full_access: bool = False + overview: bool = False + orders_view: bool = False + orders_edit: bool = False + quotations_view: bool = False + quotations_edit: bool = False + comms_view: bool = False + comms_log: bool = False + comms_edit: bool = False + comms_compose: bool = False + add: bool = False + delete: bool = False + files_view: bool = False + files_edit: bool = False + devices_view: bool = False + devices_edit: bool = False + + +class CrmProductsPermissions(BaseModel): view: bool = False add: bool = False edit: bool = False - delete: bool = False + + +class MfgPermissions(BaseModel): + view_inventory: bool = False + edit: bool = False + provision: bool = False + firmware_view: bool = False + firmware_edit: bool = False + + +class ApiReferencePermissions(BaseModel): + access: bool = False + + +class MqttPermissions(BaseModel): + access: bool = False class StaffPermissions(BaseModel): - melodies: SectionPermissions = SectionPermissions() - devices: SectionPermissions = SectionPermissions() - app_users: SectionPermissions = SectionPermissions() - equipment: SectionPermissions = SectionPermissions() - manufacturing: SectionPermissions = SectionPermissions() - mqtt: bool = False + melodies: MelodiesPermissions = MelodiesPermissions() + devices: DevicesPermissions = DevicesPermissions() + app_users: AppUsersPermissions = AppUsersPermissions() + issues_notes: IssuesNotesPermissions = IssuesNotesPermissions() + mail: MailPermissions = MailPermissions() + crm: CrmPermissions = CrmPermissions() + crm_customers: CrmCustomersPermissions = CrmCustomersPermissions() + crm_products: CrmProductsPermissions = CrmProductsPermissions() + mfg: MfgPermissions = MfgPermissions() + api_reference: ApiReferencePermissions = ApiReferencePermissions() + mqtt: MqttPermissions = MqttPermissions() -# Default permissions per role def default_permissions_for_role(role: str) -> Optional[dict]: if role in ("sysadmin", "admin"): return None # Full access, permissions field not used - full = {"view": True, "add": True, "edit": True, "delete": True} - view_only = {"view": True, "add": False, "edit": False, "delete": False} + if role == "editor": return { - "melodies": full, - "devices": full, - "app_users": full, - "equipment": full, - "manufacturing": view_only, - "mqtt": True, + "melodies": {"view": True, "add": True, "delete": True, "safe_edit": True, "full_edit": True, "archetype_access": True, "settings_access": True, "compose_access": True}, + "devices": {"view": True, "add": True, "delete": True, "safe_edit": True, "edit_bells": True, "edit_clock": True, "edit_warranty": True, "full_edit": True, "control": True}, + "app_users": {"view": True, "add": True, "delete": True, "safe_edit": True, "full_edit": True}, + "issues_notes": {"view": True, "add": True, "delete": True, "edit": True}, + "mail": {"view": True, "compose": True, "reply": True}, + "crm": {"activity_log": True}, + "crm_customers": {"full_access": True, "overview": True, "orders_view": True, "orders_edit": True, "quotations_view": True, "quotations_edit": True, "comms_view": True, "comms_log": True, "comms_edit": True, "comms_compose": True, "add": True, "delete": True, "files_view": True, "files_edit": True, "devices_view": True, "devices_edit": True}, + "crm_products": {"view": True, "add": True, "edit": True}, + "mfg": {"view_inventory": True, "edit": True, "provision": True, "firmware_view": True, "firmware_edit": True}, + "api_reference": {"access": True}, + "mqtt": {"access": True}, } + # user role - view only return { - "melodies": view_only, - "devices": view_only, - "app_users": view_only, - "equipment": view_only, - "manufacturing": {"view": False, "add": False, "edit": False, "delete": False}, - "mqtt": False, + "melodies": {"view": True, "add": False, "delete": False, "safe_edit": False, "full_edit": False, "archetype_access": False, "settings_access": False, "compose_access": False}, + "devices": {"view": True, "add": False, "delete": False, "safe_edit": False, "edit_bells": False, "edit_clock": False, "edit_warranty": False, "full_edit": False, "control": False}, + "app_users": {"view": True, "add": False, "delete": False, "safe_edit": False, "full_edit": False}, + "issues_notes": {"view": True, "add": False, "delete": False, "edit": False}, + "mail": {"view": True, "compose": False, "reply": False}, + "crm": {"activity_log": False}, + "crm_customers": {"full_access": False, "overview": True, "orders_view": True, "orders_edit": False, "quotations_view": True, "quotations_edit": False, "comms_view": True, "comms_log": False, "comms_edit": False, "comms_compose": False, "add": False, "delete": False, "files_view": True, "files_edit": False, "devices_view": True, "devices_edit": False}, + "crm_products": {"view": True, "add": False, "edit": False}, + "mfg": {"view_inventory": True, "edit": False, "provision": False, "firmware_view": True, "firmware_edit": False}, + "api_reference": {"access": False}, + "mqtt": {"access": False}, } diff --git a/backend/config.py b/backend/config.py index 007646c..2ab92c5 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,5 +1,5 @@ from pydantic_settings import BaseSettings -from typing import List +from typing import List, Dict, Any import json @@ -20,6 +20,7 @@ class Settings(BaseSettings): mqtt_admin_password: str = "" mqtt_secret: str = "change-me-in-production" mosquitto_password_file: str = "/etc/mosquitto/passwd" + mqtt_client_id: str = "bellsystems-admin-panel" # SQLite (MQTT data storage) sqlite_db_path: str = "./mqtt_data.db" @@ -37,6 +38,30 @@ class Settings(BaseSettings): backend_cors_origins: str = '["http://localhost:5173"]' debug: bool = True + # Nextcloud WebDAV + nextcloud_url: str = "" + nextcloud_username: str = "" # WebDAV login & URL path username + nextcloud_password: str = "" # Use an app password for better security + nextcloud_dav_user: str = "" # Override URL path username if different from login + nextcloud_base_path: str = "BellSystems" + + # IMAP/SMTP Email + 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 + # Multi-mailbox config (JSON array). If empty, legacy single-account IMAP/SMTP is used. + # Example item: + # {"key":"sales","label":"Sales","email":"sales@bellsystems.gr","imap_host":"...","imap_username":"...","imap_password":"...","smtp_host":"...","smtp_username":"...","smtp_password":"...","sync_inbound":true,"allow_send":true} + mail_accounts_json: str = "[]" + # Auto-deploy (Gitea webhook) deploy_secret: str = "" deploy_project_path: str = "/app" @@ -45,6 +70,14 @@ class Settings(BaseSettings): def cors_origins(self) -> List[str]: return json.loads(self.backend_cors_origins) + @property + def mail_accounts(self) -> List[Dict[str, Any]]: + try: + raw = json.loads(self.mail_accounts_json or "[]") + return raw if isinstance(raw, list) else [] + except Exception: + return [] + model_config = {"env_file": ".env", "extra": "ignore"} diff --git a/backend/crm/__init__.py b/backend/crm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/crm/comms_router.py b/backend/crm/comms_router.py new file mode 100644 index 0000000..b235994 --- /dev/null +++ b/backend/crm/comms_router.py @@ -0,0 +1,417 @@ +import base64 +import json +from fastapi import APIRouter, Depends, HTTPException, Query, Form, File, UploadFile +from pydantic import BaseModel +from typing import List, Optional + +from auth.models import TokenPayload +from auth.dependencies import require_permission +from config import settings +from crm.models import CommCreate, CommUpdate, CommInDB, CommListResponse, MediaCreate, MediaDirection +from crm import service +from crm import email_sync +from crm.mail_accounts import get_mail_accounts + +router = APIRouter(prefix="/api/crm/comms", tags=["crm-comms"]) + + +class EmailSendResponse(BaseModel): + entry: dict + + +class EmailSyncResponse(BaseModel): + new_count: int + + +class MailListResponse(BaseModel): + entries: list + total: int + + +@router.get("/all", response_model=CommListResponse) +async def list_all_comms( + type: Optional[str] = Query(None), + direction: Optional[str] = Query(None), + limit: int = Query(200, le=500), + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + entries = await service.list_all_comms(type=type, direction=direction, limit=limit) + return CommListResponse(entries=entries, total=len(entries)) + + +@router.get("", response_model=CommListResponse) +async def list_comms( + customer_id: str = Query(...), + type: Optional[str] = Query(None), + direction: Optional[str] = Query(None), + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + entries = await service.list_comms(customer_id=customer_id, type=type, direction=direction) + return CommListResponse(entries=entries, total=len(entries)) + + +@router.post("", response_model=CommInDB, status_code=201) +async def create_comm( + body: CommCreate, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + return await service.create_comm(body) + + +@router.get("/email/all", response_model=MailListResponse) +async def list_all_emails( + direction: Optional[str] = Query(None), + customers_only: bool = Query(False), + mailbox: Optional[str] = Query(None, description="sales|support|both|all or account key"), + limit: int = Query(500, le=1000), + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + """Return all email comms (all senders + unmatched), for the Mail page.""" + selected_accounts = None + if mailbox and mailbox not in {"all", "both"}: + if mailbox == "sales": + selected_accounts = ["sales"] + elif mailbox == "support": + selected_accounts = ["support"] + else: + selected_accounts = [mailbox] + entries = await service.list_all_emails( + direction=direction, + customers_only=customers_only, + mail_accounts=selected_accounts, + limit=limit, + ) + return MailListResponse(entries=entries, total=len(entries)) + + +@router.get("/email/accounts") +async def list_mail_accounts( + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + accounts = get_mail_accounts() + return { + "accounts": [ + { + "key": a["key"], + "label": a["label"], + "email": a["email"], + "sync_inbound": bool(a.get("sync_inbound")), + "allow_send": bool(a.get("allow_send")), + } + for a in accounts + ] + } + + +@router.get("/email/check") +async def check_new_emails( + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + """Lightweight check: returns how many emails are on the server vs. stored locally.""" + return await email_sync.check_new_emails() + + +# Email endpoints — must be before /{comm_id} wildcard routes +@router.post("/email/send", response_model=EmailSendResponse) +async def send_email_endpoint( + customer_id: Optional[str] = Form(None), + from_account: Optional[str] = Form(None), + to: str = Form(...), + subject: str = Form(...), + body: str = Form(...), + body_html: str = Form(""), + cc: str = Form("[]"), # JSON-encoded list of strings + files: List[UploadFile] = File(default=[]), + user: TokenPayload = Depends(require_permission("crm", "edit")), +): + if not get_mail_accounts(): + raise HTTPException(status_code=503, detail="SMTP not configured") + try: + cc_list: List[str] = json.loads(cc) if cc else [] + except Exception: + cc_list = [] + + # Read all uploaded files into memory + file_attachments = [] + for f in files: + content = await f.read() + mime_type = f.content_type or "application/octet-stream" + file_attachments.append((f.filename, content, mime_type)) + + from crm.email_sync import send_email + try: + entry = await send_email( + customer_id=customer_id or None, + from_account=from_account, + to=to, + subject=subject, + body=body, + body_html=body_html, + cc=cc_list, + sent_by=user.name or user.sub, + file_attachments=file_attachments if file_attachments else None, + ) + except RuntimeError as e: + raise HTTPException(status_code=400, detail=str(e)) + return EmailSendResponse(entry=entry) + + +@router.post("/email/sync", response_model=EmailSyncResponse) +async def sync_email_endpoint( + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + if not get_mail_accounts(): + raise HTTPException(status_code=503, detail="IMAP not configured") + from crm.email_sync import sync_emails + new_count = await sync_emails() + return EmailSyncResponse(new_count=new_count) + + +class SaveInlineRequest(BaseModel): + data_uri: str + filename: str + subfolder: str = "received_media" + mime_type: Optional[str] = None + + +async def _resolve_customer_folder(customer_id: str) -> str: + """Return the Nextcloud folder_id for a customer (falls back to customer_id).""" + from shared.firebase import get_db as get_firestore + firestore_db = get_firestore() + doc = firestore_db.collection("crm_customers").document(customer_id).get() + if not doc.exists: + raise HTTPException(status_code=404, detail="Customer not found") + data = doc.to_dict() + return data.get("folder_id") or customer_id + + +async def _upload_to_nc(folder_id: str, subfolder: str, filename: str, + content: bytes, mime_type: str, customer_id: str, + uploaded_by: str, tags: list[str]) -> dict: + from crm import nextcloud + target_folder = f"customers/{folder_id}/{subfolder}" + file_path = f"{target_folder}/{filename}" + await nextcloud.ensure_folder(target_folder) + await nextcloud.upload_file(file_path, content, mime_type) + media = await service.create_media(MediaCreate( + customer_id=customer_id, + filename=filename, + nextcloud_path=file_path, + mime_type=mime_type, + direction=MediaDirection.received, + tags=tags, + uploaded_by=uploaded_by, + )) + return {"ok": True, "media_id": media.id, "nextcloud_path": file_path} + + +@router.post("/email/{comm_id}/save-inline") +async def save_email_inline_image( + comm_id: str, + body: SaveInlineRequest, + user: TokenPayload = Depends(require_permission("crm", "edit")), +): + """Save an inline image (data-URI from email HTML body) to Nextcloud.""" + comm = await service.get_comm(comm_id) + customer_id = comm.customer_id + if not customer_id: + raise HTTPException(status_code=400, detail="This email is not linked to a customer") + + folder_id = await _resolve_customer_folder(customer_id) + + # Parse data URI + data_uri = body.data_uri + mime_type = body.mime_type or "image/png" + if "," in data_uri: + header, encoded = data_uri.split(",", 1) + try: + mime_type = header.split(":")[1].split(";")[0] + except Exception: + pass + else: + encoded = data_uri + try: + content = base64.b64decode(encoded) + except Exception: + raise HTTPException(status_code=400, detail="Invalid base64 data") + + return await _upload_to_nc( + folder_id, body.subfolder, body.filename, + content, mime_type, customer_id, + user.name or user.sub, ["email-inline-image"], + ) + + +@router.post("/email/{comm_id}/save-attachment/{attachment_index}") +async def save_email_attachment( + comm_id: str, + attachment_index: int, + filename: str = Form(...), + subfolder: str = Form("received_media"), + user: TokenPayload = Depends(require_permission("crm", "edit")), +): + """ + Re-fetch a specific attachment from IMAP (by index in the email's attachment list) + and save it to the customer's Nextcloud media folder. + """ + import asyncio + comm = await service.get_comm(comm_id) + customer_id = comm.customer_id + if not customer_id: + raise HTTPException(status_code=400, detail="This email is not linked to a customer") + + ext_message_id = comm.ext_message_id + if not ext_message_id: + raise HTTPException(status_code=400, detail="No message ID stored for this email") + + attachments_meta = comm.attachments or [] + if attachment_index < 0 or attachment_index >= len(attachments_meta): + raise HTTPException(status_code=400, detail="Attachment index out of range") + + att_meta = attachments_meta[attachment_index] + mime_type = att_meta.content_type or "application/octet-stream" + from crm.mail_accounts import account_by_key, account_by_email + account = account_by_key(comm.mail_account) or account_by_email(comm.from_addr) + if not account: + raise HTTPException(status_code=400, detail="Email account config not found for this message") + + # Re-fetch from IMAP in executor + def _fetch_attachment(): + import imaplib, email as _email + if account.get("imap_use_ssl"): + imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"])) + else: + imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"])) + imap.login(account["imap_username"], account["imap_password"]) + imap.select(account.get("imap_inbox", "INBOX")) + + # Search by Message-ID header + _, data = imap.search(None, f'HEADER Message-ID "{ext_message_id}"') + uids = data[0].split() if data[0] else [] + if not uids: + raise ValueError(f"Message not found on IMAP server: {ext_message_id}") + + _, msg_data = imap.fetch(uids[0], "(RFC822)") + raw = msg_data[0][1] + msg = _email.message_from_bytes(raw) + imap.logout() + + # Walk attachments in order — find the one at attachment_index + found_idx = 0 + for part in msg.walk(): + cd = str(part.get("Content-Disposition", "")) + if "attachment" not in cd: + continue + if found_idx == attachment_index: + payload = part.get_payload(decode=True) + if payload is None: + raise ValueError("Attachment payload is empty") + return payload + found_idx += 1 + + raise ValueError(f"Attachment index {attachment_index} not found in message") + + loop = asyncio.get_event_loop() + try: + content = await loop.run_in_executor(None, _fetch_attachment) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=502, detail=f"IMAP fetch failed: {e}") + + folder_id = await _resolve_customer_folder(customer_id) + return await _upload_to_nc( + folder_id, subfolder, filename, + content, mime_type, customer_id, + user.name or user.sub, ["email-attachment"], + ) + + +class BulkDeleteRequest(BaseModel): + ids: List[str] + + +class ToggleImportantRequest(BaseModel): + important: bool + + +class ToggleReadRequest(BaseModel): + read: bool + + +@router.post("/bulk-delete", status_code=200) +async def bulk_delete_comms( + body: BulkDeleteRequest, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + # Try remote IMAP delete for email rows first (best-effort), then local delete. + for comm_id in body.ids: + try: + comm = await service.get_comm(comm_id) + if comm.type == "email" and comm.ext_message_id: + await email_sync.delete_remote_email( + comm.ext_message_id, + comm.mail_account, + comm.from_addr, + ) + except Exception: + # Keep delete resilient; local delete still proceeds. + pass + count = await service.delete_comms_bulk(body.ids) + return {"deleted": count} + + +@router.patch("/{comm_id}/important", response_model=CommInDB) +async def set_comm_important( + comm_id: str, + body: ToggleImportantRequest, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + return await service.set_comm_important(comm_id, body.important) + + +@router.patch("/{comm_id}/read", response_model=CommInDB) +async def set_comm_read( + comm_id: str, + body: ToggleReadRequest, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + try: + comm = await service.get_comm(comm_id) + if comm.type == "email" and comm.ext_message_id: + await email_sync.set_remote_read( + comm.ext_message_id, + comm.mail_account, + comm.from_addr, + body.read, + ) + except Exception: + pass + return await service.set_comm_read(comm_id, body.read) + + +@router.put("/{comm_id}", response_model=CommInDB) +async def update_comm( + comm_id: str, + body: CommUpdate, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + return await service.update_comm(comm_id, body) + + +@router.delete("/{comm_id}", status_code=204) +async def delete_comm( + comm_id: str, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + try: + comm = await service.get_comm(comm_id) + if comm.type == "email" and comm.ext_message_id: + await email_sync.delete_remote_email( + comm.ext_message_id, + comm.mail_account, + comm.from_addr, + ) + except Exception: + pass + await service.delete_comm(comm_id) diff --git a/backend/crm/customers_router.py b/backend/crm/customers_router.py new file mode 100644 index 0000000..ade091f --- /dev/null +++ b/backend/crm/customers_router.py @@ -0,0 +1,71 @@ +import asyncio +import logging +from fastapi import APIRouter, Depends, Query, BackgroundTasks +from typing import Optional + +from auth.models import TokenPayload +from auth.dependencies import require_permission +from crm.models import CustomerCreate, CustomerUpdate, CustomerInDB, CustomerListResponse +from crm import service, nextcloud +from config import settings + +router = APIRouter(prefix="/api/crm/customers", tags=["crm-customers"]) +logger = logging.getLogger(__name__) + + +@router.get("", response_model=CustomerListResponse) +def list_customers( + search: Optional[str] = Query(None), + tag: Optional[str] = Query(None), + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + customers = service.list_customers(search=search, tag=tag) + return CustomerListResponse(customers=customers, total=len(customers)) + + +@router.get("/{customer_id}", response_model=CustomerInDB) +def get_customer( + customer_id: str, + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + return service.get_customer(customer_id) + + +@router.post("", response_model=CustomerInDB, status_code=201) +async def create_customer( + body: CustomerCreate, + background_tasks: BackgroundTasks, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + customer = service.create_customer(body) + if settings.nextcloud_url: + background_tasks.add_task(_init_nextcloud_folder, customer) + return customer + + +async def _init_nextcloud_folder(customer) -> None: + try: + nc_path = service.get_customer_nc_path(customer) + base = f"customers/{nc_path}" + for sub in ("media", "documents", "sent", "received"): + await nextcloud.ensure_folder(f"{base}/{sub}") + await nextcloud.write_info_file(base, customer.name, customer.id) + except Exception as e: + logger.warning("Nextcloud folder init failed for customer %s: %s", customer.id, e) + + +@router.put("/{customer_id}", response_model=CustomerInDB) +def update_customer( + customer_id: str, + body: CustomerUpdate, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + return service.update_customer(customer_id, body) + + +@router.delete("/{customer_id}", status_code=204) +def delete_customer( + customer_id: str, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + service.delete_customer(customer_id) diff --git a/backend/crm/email_sync.py b/backend/crm/email_sync.py new file mode 100644 index 0000000..1829557 --- /dev/null +++ b/backend/crm/email_sync.py @@ -0,0 +1,837 @@ +""" +IMAP email sync and SMTP email send for CRM. +Uses only stdlib imaplib/smtplib — no extra dependencies. +Sync is run in an executor to avoid blocking the event loop. +""" +import asyncio +import base64 +import email +import email.header +import email.utils +import html.parser +import imaplib +import json +import logging +import re +import smtplib +import uuid +from datetime import datetime, timezone +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email import encoders +from typing import List, Optional, Tuple + +from config import settings +from mqtt import database as mqtt_db +from crm.mail_accounts import get_mail_accounts, account_by_key, account_by_email + +logger = logging.getLogger("crm.email_sync") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _decode_header(raw: str) -> str: + """Decode an RFC2047-encoded email header value.""" + if not raw: + return "" + parts = email.header.decode_header(raw) + decoded = [] + for part, enc in parts: + if isinstance(part, bytes): + decoded.append(part.decode(enc or "utf-8", errors="replace")) + else: + decoded.append(part) + return " ".join(decoded) + + +class _HTMLStripper(html.parser.HTMLParser): + def __init__(self): + super().__init__() + self._text = [] + + def handle_data(self, data): + self._text.append(data) + + def get_text(self): + return " ".join(self._text) + + +def _strip_html(html_str: str) -> str: + s = _HTMLStripper() + s.feed(html_str) + return s.get_text() + + +def _extract_inline_data_images(html_body: str) -> tuple[str, list[tuple[str, bytes, str]]]: + """Replace data-URI images in HTML with cid: references and return inline parts. + Returns: (new_html, [(cid, image_bytes, mime_type), ...]) + """ + if not html_body: + return "", [] + + inline_parts: list[tuple[str, bytes, str]] = [] + seen: dict[str, str] = {} # data-uri -> cid + + src_pattern = re.compile(r"""src=(['"])(data:image/[^'"]+)\1""", re.IGNORECASE) + data_pattern = re.compile(r"^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$", re.IGNORECASE | re.DOTALL) + + def _replace(match: re.Match) -> str: + quote = match.group(1) + data_uri = match.group(2) + + if data_uri in seen: + cid = seen[data_uri] + return f"src={quote}cid:{cid}{quote}" + + parsed = data_pattern.match(data_uri) + if not parsed: + return match.group(0) + + mime_type = parsed.group(1).lower() + b64_data = parsed.group(2).strip() + try: + payload = base64.b64decode(b64_data, validate=False) + except Exception: + return match.group(0) + + cid = f"inline-{uuid.uuid4().hex}" + seen[data_uri] = cid + inline_parts.append((cid, payload, mime_type)) + return f"src={quote}cid:{cid}{quote}" + + return src_pattern.sub(_replace, html_body), inline_parts + + +def _load_customer_email_map() -> dict[str, str]: + """Build a lookup of customer email -> customer_id from Firestore.""" + from shared.firebase import get_db as get_firestore + firestore_db = get_firestore() + addr_to_customer: dict[str, str] = {} + for doc in firestore_db.collection("crm_customers").stream(): + data = doc.to_dict() or {} + for contact in (data.get("contacts") or []): + if contact.get("type") == "email" and contact.get("value"): + addr_to_customer[str(contact["value"]).strip().lower()] = doc.id + return addr_to_customer + + +def _get_body(msg: email.message.Message) -> tuple[str, str]: + """Extract (plain_text, html_body) from an email message. + Inline images (cid: references) are substituted with data-URIs so they + render correctly in a sandboxed iframe without external requests. + """ + import base64 as _b64 + plain = None + html_body = None + # Map Content-ID → data-URI for inline images + cid_map: dict[str, str] = {} + + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + cd = str(part.get("Content-Disposition", "")) + cid = part.get("Content-ID", "").strip().strip("<>") + + if "attachment" in cd: + continue + + if ct == "text/plain" and plain is None: + raw = part.get_payload(decode=True) + charset = part.get_content_charset() or "utf-8" + plain = raw.decode(charset, errors="replace") + elif ct == "text/html" and html_body is None: + raw = part.get_payload(decode=True) + charset = part.get_content_charset() or "utf-8" + html_body = raw.decode(charset, errors="replace") + elif ct.startswith("image/") and cid: + raw = part.get_payload(decode=True) + if raw: + b64 = _b64.b64encode(raw).decode("ascii") + cid_map[cid] = f"data:{ct};base64,{b64}" + else: + ct = msg.get_content_type() + payload = msg.get_payload(decode=True) + charset = msg.get_content_charset() or "utf-8" + if payload: + text = payload.decode(charset, errors="replace") + if ct == "text/plain": + plain = text + elif ct == "text/html": + html_body = text + + # Substitute cid: references with data-URIs + if html_body and cid_map: + for cid, data_uri in cid_map.items(): + html_body = html_body.replace(f"cid:{cid}", data_uri) + + plain_text = (plain or (html_body and _strip_html(html_body)) or "").strip() + return plain_text, (html_body or "").strip() + + +def _get_attachments(msg: email.message.Message) -> list[dict]: + """Extract attachment info (filename, content_type, size) without storing content.""" + attachments = [] + if msg.is_multipart(): + for part in msg.walk(): + cd = str(part.get("Content-Disposition", "")) + if "attachment" in cd: + filename = part.get_filename() or "attachment" + filename = _decode_header(filename) + ct = part.get_content_type() or "application/octet-stream" + payload = part.get_payload(decode=True) + size = len(payload) if payload else 0 + attachments.append({"filename": filename, "content_type": ct, "size": size}) + return attachments + + +# --------------------------------------------------------------------------- +# IMAP sync (synchronous — called via run_in_executor) +# --------------------------------------------------------------------------- + +def _sync_account_emails_sync(account: dict) -> tuple[list[dict], bool]: + if not account.get("imap_host") or not account.get("imap_username") or not account.get("imap_password"): + return [], False + if account.get("imap_use_ssl"): + imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"])) + else: + imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"])) + imap.login(account["imap_username"], account["imap_password"]) + # readonly=True prevents marking messages as \Seen while syncing. + imap.select(account.get("imap_inbox", "INBOX"), readonly=True) + _, data = imap.search(None, "ALL") + uids = data[0].split() if data[0] else [] + + results = [] + complete = True + for uid in uids: + try: + _, msg_data = imap.fetch(uid, "(FLAGS RFC822)") + meta = msg_data[0][0] if msg_data and isinstance(msg_data[0], tuple) else b"" + raw = msg_data[0][1] + msg = email.message_from_bytes(raw) + message_id = msg.get("Message-ID", "").strip() + from_addr = email.utils.parseaddr(msg.get("From", ""))[1] + to_addrs_raw = msg.get("To", "") + to_addrs = [a for _, a in email.utils.getaddresses([to_addrs_raw])] + subject = _decode_header(msg.get("Subject", "")) + date_str = msg.get("Date", "") + try: + occurred_at = email.utils.parsedate_to_datetime(date_str).isoformat() + except Exception: + occurred_at = datetime.now(timezone.utc).isoformat() + is_read = b"\\Seen" in (meta or b"") + try: + body, body_html = _get_body(msg) + except Exception: + body, body_html = "", "" + try: + file_attachments = _get_attachments(msg) + except Exception: + file_attachments = [] + results.append({ + "mail_account": account["key"], + "message_id": message_id, + "from_addr": from_addr, + "to_addrs": to_addrs, + "subject": subject, + "body": body, + "body_html": body_html, + "attachments": file_attachments, + "occurred_at": occurred_at, + "is_read": bool(is_read), + }) + except Exception as e: + complete = False + logger.warning(f"[EMAIL SYNC] Failed to parse message uid={uid} account={account['key']}: {e}") + imap.logout() + return results, complete + + +def _sync_emails_sync() -> tuple[list[dict], bool]: + all_msgs: list[dict] = [] + all_complete = True + # Deduplicate by physical inbox source. Aliases often share the same mailbox. + seen_sources: set[tuple] = set() + for acc in get_mail_accounts(): + if not acc.get("sync_inbound"): + continue + source = ( + (acc.get("imap_host") or "").lower(), + int(acc.get("imap_port") or 0), + (acc.get("imap_username") or "").lower(), + (acc.get("imap_inbox") or "INBOX").upper(), + ) + if source in seen_sources: + continue + seen_sources.add(source) + msgs, complete = _sync_account_emails_sync(acc) + all_msgs.extend(msgs) + all_complete = all_complete and complete + return all_msgs, all_complete + + +async def sync_emails() -> int: + """ + Pull emails from IMAP, match against CRM customers, store new ones. + Returns count of new entries created. + """ + if not get_mail_accounts(): + return 0 + + loop = asyncio.get_event_loop() + try: + messages, fetch_complete = await loop.run_in_executor(None, _sync_emails_sync) + except Exception as e: + logger.error(f"[EMAIL SYNC] IMAP connect/fetch failed: {e}") + raise + + db = await mqtt_db.get_db() + + # Load all customer email contacts into a flat lookup: email -> customer_id + addr_to_customer = _load_customer_email_map() + + # Load already-synced message-ids from DB + rows = await db.execute_fetchall( + "SELECT id, ext_message_id, COALESCE(mail_account, '') as mail_account, direction, is_read, customer_id " + "FROM crm_comms_log WHERE type='email' AND ext_message_id IS NOT NULL" + ) + known_map = { + (r[1], r[2] or ""): { + "id": r[0], + "direction": r[3], + "is_read": int(r[4] or 0), + "customer_id": r[5], + } + for r in rows + } + + new_count = 0 + now = datetime.now(timezone.utc).isoformat() + server_ids_by_account: dict[str, set[str]] = {} + # Global inbound IDs from server snapshot, used to avoid account-classification delete oscillation. + inbound_server_ids: set[str] = set() + accounts = get_mail_accounts() + accounts_by_email = {a["email"].lower(): a for a in accounts} + # Initialize tracked inbound accounts even if inbox is empty. + for a in accounts: + if a.get("sync_inbound"): + server_ids_by_account[a["key"]] = set() + + for msg in messages: + mid = msg["message_id"] + fetch_account_key = (msg.get("mail_account") or "").strip().lower() + from_addr = msg["from_addr"].lower() + to_addrs = [a.lower() for a in msg["to_addrs"]] + + sender_acc = accounts_by_email.get(from_addr) + if sender_acc: + direction = "outbound" + resolved_account_key = sender_acc["key"] + customer_addrs = to_addrs + else: + direction = "inbound" + target_acc = None + for addr in to_addrs: + if addr in accounts_by_email: + target_acc = accounts_by_email[addr] + break + resolved_account_key = (target_acc["key"] if target_acc else fetch_account_key) + customer_addrs = [from_addr] + if target_acc and not target_acc.get("sync_inbound"): + # Ignore inbound for non-synced aliases (e.g. info/news). + continue + + if direction == "inbound" and mid and resolved_account_key in server_ids_by_account: + server_ids_by_account[resolved_account_key].add(mid) + inbound_server_ids.add(mid) + # Find matching customer (may be None - we still store the email) + customer_id = None + for addr in customer_addrs: + if addr in addr_to_customer: + customer_id = addr_to_customer[addr] + break + + if mid and (mid, resolved_account_key) in known_map: + existing = known_map[(mid, resolved_account_key)] + # Backfill customer linkage for rows created without customer_id. + if customer_id and not existing.get("customer_id"): + await db.execute( + "UPDATE crm_comms_log SET customer_id=? WHERE id=?", + (customer_id, existing["id"]), + ) + # Existing inbound message: sync read/unread state from server. + if direction == "inbound": + server_read = 1 if msg.get("is_read") else 0 + await db.execute( + "UPDATE crm_comms_log SET is_read=? " + "WHERE type='email' AND direction='inbound' AND ext_message_id=? AND mail_account=?", + (server_read, mid, resolved_account_key), + ) + continue # already stored + + attachments_json = json.dumps(msg.get("attachments") or []) + to_addrs_json = json.dumps(to_addrs) + + entry_id = str(uuid.uuid4()) + await db.execute( + """INSERT INTO crm_comms_log + (id, customer_id, type, mail_account, direction, subject, body, body_html, attachments, + ext_message_id, from_addr, to_addrs, logged_by, occurred_at, created_at, is_read) + VALUES (?, ?, 'email', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'system', ?, ?, ?)""", + (entry_id, customer_id, resolved_account_key, direction, msg["subject"], msg["body"], + msg.get("body_html", ""), attachments_json, + mid, from_addr, to_addrs_json, msg["occurred_at"], now, 1 if msg.get("is_read") else 0), + ) + new_count += 1 + + # Mirror remote deletes based on global inbound message-id snapshot. + # To avoid transient IMAP inconsistency causing add/remove oscillation, + # require two consecutive "missing" syncs before local deletion. + sync_keys = [a["key"] for a in accounts if a.get("sync_inbound")] + if sync_keys and fetch_complete: + placeholders = ",".join("?" for _ in sync_keys) + local_rows = await db.execute_fetchall( + f"SELECT id, ext_message_id, mail_account FROM crm_comms_log " + f"WHERE type='email' AND direction='inbound' AND mail_account IN ({placeholders}) " + "AND ext_message_id IS NOT NULL", + sync_keys, + ) + to_delete: list[str] = [] + for row in local_rows: + row_id, ext_id, acc_key = row[0], row[1], row[2] + if not ext_id: + continue + state_key = f"missing_email::{acc_key}::{ext_id}" + if ext_id in inbound_server_ids: + await db.execute("DELETE FROM crm_sync_state WHERE key = ?", (state_key,)) + continue + prev = await db.execute_fetchall("SELECT value FROM crm_sync_state WHERE key = ?", (state_key,)) + prev_count = int(prev[0][0]) if prev and (prev[0][0] or "").isdigit() else 0 + new_count = prev_count + 1 + await db.execute( + "INSERT INTO crm_sync_state (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (state_key, str(new_count)), + ) + if new_count >= 2: + to_delete.append(row_id) + await db.execute("DELETE FROM crm_sync_state WHERE key = ?", (state_key,)) + if to_delete: + del_ph = ",".join("?" for _ in to_delete) + await db.execute(f"DELETE FROM crm_comms_log WHERE id IN ({del_ph})", to_delete) + + if new_count or server_ids_by_account: + await db.commit() + + # Update last sync time + await db.execute( + "INSERT INTO crm_sync_state (key, value) VALUES ('last_email_sync', ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (now,), + ) + await db.commit() + + logger.info(f"[EMAIL SYNC] Done — {new_count} new emails stored") + return new_count + + +# --------------------------------------------------------------------------- +# Lightweight new-mail check (synchronous — called via run_in_executor) +# --------------------------------------------------------------------------- + +def _check_server_count_sync() -> int: + # Keep this for backward compatibility; no longer used by check_new_emails(). + total = 0 + seen_sources: set[tuple] = set() + for acc in get_mail_accounts(): + if not acc.get("sync_inbound"): + continue + source = ( + (acc.get("imap_host") or "").lower(), + int(acc.get("imap_port") or 0), + (acc.get("imap_username") or "").lower(), + (acc.get("imap_inbox") or "INBOX").upper(), + ) + if source in seen_sources: + continue + seen_sources.add(source) + if acc.get("imap_use_ssl"): + imap = imaplib.IMAP4_SSL(acc["imap_host"], int(acc["imap_port"])) + else: + imap = imaplib.IMAP4(acc["imap_host"], int(acc["imap_port"])) + imap.login(acc["imap_username"], acc["imap_password"]) + imap.select(acc.get("imap_inbox", "INBOX"), readonly=True) + _, data = imap.search(None, "ALL") + total += len(data[0].split()) if data[0] else 0 + imap.logout() + return total + + +async def check_new_emails() -> dict: + """ + Compare server message count vs. locally stored count. + Returns {"new_count": int} — does NOT download or store anything. + """ + if not get_mail_accounts(): + return {"new_count": 0} + + loop = asyncio.get_event_loop() + try: + # Reuse same account-resolution logic as sync to avoid false positives. + messages, _ = await loop.run_in_executor(None, _sync_emails_sync) + except Exception as e: + logger.warning(f"[EMAIL CHECK] IMAP check failed: {e}") + raise + + accounts = get_mail_accounts() + accounts_by_email = {a["email"].lower(): a for a in accounts} + db = await mqtt_db.get_db() + rows = await db.execute_fetchall( + "SELECT ext_message_id, COALESCE(mail_account, '') as mail_account FROM crm_comms_log " + "WHERE type='email' AND ext_message_id IS NOT NULL" + ) + known_ids = {(r[0], r[1] or "") for r in rows} + + new_count = 0 + for msg in messages: + mid = (msg.get("message_id") or "").strip() + if not mid: + continue + fetch_account_key = (msg.get("mail_account") or "").strip().lower() + from_addr = (msg.get("from_addr") or "").lower() + to_addrs = [(a or "").lower() for a in (msg.get("to_addrs") or [])] + + sender_acc = accounts_by_email.get(from_addr) + if sender_acc: + # Outbound copy in mailbox; not part of "new inbound mail" banner. + continue + + target_acc = None + for addr in to_addrs: + if addr in accounts_by_email: + target_acc = accounts_by_email[addr] + break + resolved_account_key = (target_acc["key"] if target_acc else fetch_account_key) + if target_acc and not target_acc.get("sync_inbound"): + continue + if (mid, resolved_account_key) not in known_ids: + new_count += 1 + + return {"new_count": new_count} + + +# --------------------------------------------------------------------------- +# SMTP send (synchronous — called via run_in_executor) +# --------------------------------------------------------------------------- + +def _append_to_sent_sync(account: dict, raw_message: bytes) -> None: + """Best-effort append of sent MIME message to IMAP Sent folder.""" + if not raw_message: + return + try: + if account.get("imap_use_ssl"): + imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"])) + else: + imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"])) + imap.login(account["imap_username"], account["imap_password"]) + + preferred = str(account.get("imap_sent") or "Sent").strip() or "Sent" + candidates = [preferred, "Sent", "INBOX.Sent", "Sent Items", "INBOX.Sent Items"] + seen = set() + ordered_candidates = [] + for name in candidates: + key = name.lower() + if key not in seen: + seen.add(key) + ordered_candidates.append(name) + + appended = False + for mailbox in ordered_candidates: + try: + status, _ = imap.append(mailbox, "\\Seen", None, raw_message) + if status == "OK": + appended = True + break + except Exception: + continue + + if not appended: + logger.warning("[EMAIL SEND] Sent copy append failed for account=%s", account.get("key")) + imap.logout() + except Exception as e: + logger.warning("[EMAIL SEND] IMAP append to Sent failed for account=%s: %s", account.get("key"), e) + + +def _send_email_sync( + account: dict, + to: str, + subject: str, + body: str, + body_html: str, + cc: List[str], + file_attachments: Optional[List[Tuple[str, bytes, str]]] = None, +) -> str: + """Send via SMTP. Returns the Message-ID header. + file_attachments: list of (filename, content_bytes, mime_type) + """ + html_with_cids, inline_images = _extract_inline_data_images(body_html or "") + + # Build body tree: + # - with inline images: related(alternative(text/plain, text/html), image parts) + # - without inline images: alternative(text/plain, text/html) + if inline_images: + body_part = MIMEMultipart("related") + alt_part = MIMEMultipart("alternative") + alt_part.attach(MIMEText(body, "plain", "utf-8")) + if html_with_cids: + alt_part.attach(MIMEText(html_with_cids, "html", "utf-8")) + body_part.attach(alt_part) + + for idx, (cid, content, mime_type) in enumerate(inline_images, start=1): + maintype, _, subtype = mime_type.partition("/") + img_part = MIMEBase(maintype or "image", subtype or "png") + img_part.set_payload(content) + encoders.encode_base64(img_part) + img_part.add_header("Content-ID", f"<{cid}>") + img_part.add_header("Content-Disposition", "inline", filename=f"inline-{idx}.{subtype or 'png'}") + body_part.attach(img_part) + else: + body_part = MIMEMultipart("alternative") + body_part.attach(MIMEText(body, "plain", "utf-8")) + if body_html: + body_part.attach(MIMEText(body_html, "html", "utf-8")) + + # Wrap with mixed only when classic file attachments exist. + if file_attachments: + msg = MIMEMultipart("mixed") + msg.attach(body_part) + else: + msg = body_part + + from_addr = account["email"] + msg["From"] = from_addr + msg["To"] = to + msg["Subject"] = subject + if cc: + msg["Cc"] = ", ".join(cc) + + msg_id = f"<{uuid.uuid4()}@bellsystems>" + msg["Message-ID"] = msg_id + + # Attach files + for filename, content, mime_type in (file_attachments or []): + maintype, _, subtype = mime_type.partition("/") + part = MIMEBase(maintype or "application", subtype or "octet-stream") + part.set_payload(content) + encoders.encode_base64(part) + part.add_header("Content-Disposition", "attachment", filename=filename) + msg.attach(part) + + recipients = [to] + cc + raw_for_append = msg.as_bytes() + if account.get("smtp_use_tls"): + server = smtplib.SMTP(account["smtp_host"], int(account["smtp_port"])) + server.starttls() + else: + server = smtplib.SMTP_SSL(account["smtp_host"], int(account["smtp_port"])) + + server.login(account["smtp_username"], account["smtp_password"]) + server.sendmail(from_addr, recipients, msg.as_string()) + server.quit() + _append_to_sent_sync(account, raw_for_append) + + return msg_id + + +async def send_email( + customer_id: str | None, + from_account: str | None, + to: str, + subject: str, + body: str, + body_html: str, + cc: List[str], + sent_by: str, + file_attachments: Optional[List[Tuple[str, bytes, str]]] = None, +) -> dict: + """Send an email and record it in crm_comms_log. Returns the new log entry. + file_attachments: list of (filename, content_bytes, mime_type) + """ + accounts = get_mail_accounts() + if not accounts: + raise RuntimeError("SMTP not configured") + account = account_by_key(from_account) if from_account else None + if not account: + raise RuntimeError("Please select a valid sender account") + if not account.get("allow_send"): + raise RuntimeError("Selected account is not allowed to send") + if not account.get("smtp_host") or not account.get("smtp_username") or not account.get("smtp_password"): + raise RuntimeError("SMTP not configured for selected account") + + # If the caller did not provide a customer_id (e.g. compose from Mail page), + # auto-link by matching recipient addresses against CRM customer emails. + resolved_customer_id = customer_id + if not resolved_customer_id: + addr_to_customer = _load_customer_email_map() + rcpts = [to, *cc] + parsed_rcpts = [addr for _, addr in email.utils.getaddresses(rcpts) if addr] + for addr in parsed_rcpts: + key = (addr or "").strip().lower() + if key in addr_to_customer: + resolved_customer_id = addr_to_customer[key] + break + + loop = asyncio.get_event_loop() + import functools + msg_id = await loop.run_in_executor( + None, + functools.partial(_send_email_sync, account, to, subject, body, body_html, cc, file_attachments or []), + ) + + # Upload attachments to Nextcloud and register in crm_media + comm_attachments = [] + if file_attachments and resolved_customer_id: + from crm import nextcloud, service + from crm.models import MediaCreate, MediaDirection + from shared.firebase import get_db as get_firestore + firestore_db = get_firestore() + doc = firestore_db.collection("crm_customers").document(resolved_customer_id).get() + if doc.exists: + data = doc.to_dict() + # Build a minimal CustomerInDB-like object for get_customer_nc_path + folder_id = data.get("folder_id") or resolved_customer_id + nc_path = folder_id + + for filename, content, mime_type in file_attachments: + # images/video → sent_media, everything else → documents + if mime_type.startswith("image/") or mime_type.startswith("video/"): + subfolder = "sent_media" + else: + subfolder = "documents" + target_folder = f"customers/{nc_path}/{subfolder}" + file_path = f"{target_folder}/{filename}" + try: + await nextcloud.ensure_folder(target_folder) + await nextcloud.upload_file(file_path, content, mime_type) + await service.create_media(MediaCreate( + customer_id=resolved_customer_id, + filename=filename, + nextcloud_path=file_path, + mime_type=mime_type, + direction=MediaDirection.sent, + tags=["email-attachment"], + uploaded_by=sent_by, + )) + comm_attachments.append({"filename": filename, "nextcloud_path": file_path}) + except Exception as e: + logger.warning(f"[EMAIL SEND] Failed to upload attachment {filename}: {e}") + + now = datetime.now(timezone.utc).isoformat() + entry_id = str(uuid.uuid4()) + db = await mqtt_db.get_db() + our_addr = account["email"].lower() + to_addrs_json = json.dumps([to] + cc) + attachments_json = json.dumps(comm_attachments) + await db.execute( + """INSERT INTO crm_comms_log + (id, customer_id, type, mail_account, direction, subject, body, body_html, attachments, + ext_message_id, from_addr, to_addrs, logged_by, occurred_at, created_at) + VALUES (?, ?, 'email', ?, 'outbound', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (entry_id, resolved_customer_id, account["key"], subject, body, body_html, attachments_json, msg_id, + our_addr, to_addrs_json, sent_by, now, now), + ) + await db.commit() + + return { + "id": entry_id, + "customer_id": resolved_customer_id, + "type": "email", + "mail_account": account["key"], + "direction": "outbound", + "subject": subject, + "body": body, + "body_html": body_html, + "attachments": comm_attachments, + "ext_message_id": msg_id, + "from_addr": our_addr, + "to_addrs": [to] + cc, + "logged_by": sent_by, + "occurred_at": now, + "created_at": now, + } + + +def _delete_remote_email_sync(account: dict, ext_message_id: str) -> bool: + if not ext_message_id: + return False + if account.get("imap_use_ssl"): + imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"])) + else: + imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"])) + imap.login(account["imap_username"], account["imap_password"]) + imap.select(account.get("imap_inbox", "INBOX")) + _, data = imap.search(None, f'HEADER Message-ID "{ext_message_id}"') + uids = data[0].split() if data and data[0] else [] + if not uids: + imap.logout() + return False + for uid in uids: + imap.store(uid, "+FLAGS", "\\Deleted") + imap.expunge() + imap.logout() + return True + + +async def delete_remote_email(ext_message_id: str, mail_account: str | None, from_addr: str | None = None) -> bool: + account = account_by_key(mail_account) if mail_account else None + if not account: + account = account_by_email(from_addr) + if not account or not account.get("imap_host"): + return False + loop = asyncio.get_event_loop() + try: + return await loop.run_in_executor(None, lambda: _delete_remote_email_sync(account, ext_message_id)) + except Exception as e: + logger.warning(f"[EMAIL DELETE] Failed remote delete for {ext_message_id}: {e}") + return False + + +def _set_remote_read_sync(account: dict, ext_message_id: str, read: bool) -> bool: + if not ext_message_id: + return False + if account.get("imap_use_ssl"): + imap = imaplib.IMAP4_SSL(account["imap_host"], int(account["imap_port"])) + else: + imap = imaplib.IMAP4(account["imap_host"], int(account["imap_port"])) + imap.login(account["imap_username"], account["imap_password"]) + imap.select(account.get("imap_inbox", "INBOX")) + _, data = imap.search(None, f'HEADER Message-ID "{ext_message_id}"') + uids = data[0].split() if data and data[0] else [] + if not uids: + imap.logout() + return False + flag_op = "+FLAGS" if read else "-FLAGS" + for uid in uids: + imap.store(uid, flag_op, "\\Seen") + imap.logout() + return True + + +async def set_remote_read(ext_message_id: str, mail_account: str | None, from_addr: str | None, read: bool) -> bool: + account = account_by_key(mail_account) if mail_account else None + if not account: + account = account_by_email(from_addr) + if not account or not account.get("imap_host"): + return False + loop = asyncio.get_event_loop() + try: + return await loop.run_in_executor(None, lambda: _set_remote_read_sync(account, ext_message_id, read)) + except Exception as e: + logger.warning(f"[EMAIL READ] Failed remote read update for {ext_message_id}: {e}") + return False + + + diff --git a/backend/crm/mail_accounts.py b/backend/crm/mail_accounts.py new file mode 100644 index 0000000..d531574 --- /dev/null +++ b/backend/crm/mail_accounts.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import Any + +from config import settings + + +def _bool(v: Any, default: bool) -> bool: + if isinstance(v, bool): + return v + if isinstance(v, str): + return v.strip().lower() in {"1", "true", "yes", "on"} + if v is None: + return default + return bool(v) + + +def get_mail_accounts() -> list[dict]: + """ + Returns normalized account dictionaries. + Falls back to legacy single-account config if MAIL_ACCOUNTS_JSON is empty. + """ + configured = settings.mail_accounts + normalized: list[dict] = [] + + for idx, raw in enumerate(configured): + if not isinstance(raw, dict): + continue + key = str(raw.get("key") or "").strip().lower() + email = str(raw.get("email") or "").strip().lower() + if not key or not email: + continue + normalized.append( + { + "key": key, + "label": str(raw.get("label") or key.title()), + "email": email, + "imap_host": raw.get("imap_host") or settings.imap_host, + "imap_port": int(raw.get("imap_port") or settings.imap_port or 993), + "imap_username": raw.get("imap_username") or email, + "imap_password": raw.get("imap_password") or settings.imap_password, + "imap_use_ssl": _bool(raw.get("imap_use_ssl"), settings.imap_use_ssl), + "imap_inbox": str(raw.get("imap_inbox") or "INBOX"), + "imap_sent": str(raw.get("imap_sent") or "Sent"), + "smtp_host": raw.get("smtp_host") or settings.smtp_host, + "smtp_port": int(raw.get("smtp_port") or settings.smtp_port or 587), + "smtp_username": raw.get("smtp_username") or email, + "smtp_password": raw.get("smtp_password") or settings.smtp_password, + "smtp_use_tls": _bool(raw.get("smtp_use_tls"), settings.smtp_use_tls), + "sync_inbound": _bool(raw.get("sync_inbound"), True), + "allow_send": _bool(raw.get("allow_send"), True), + } + ) + + if normalized: + return normalized + + # Legacy single-account fallback + if settings.imap_host or settings.smtp_host: + legacy_email = (settings.smtp_username or settings.imap_username or "").strip().lower() + if legacy_email: + return [ + { + "key": "default", + "label": "Default", + "email": legacy_email, + "imap_host": settings.imap_host, + "imap_port": settings.imap_port, + "imap_username": settings.imap_username, + "imap_password": settings.imap_password, + "imap_use_ssl": settings.imap_use_ssl, + "imap_inbox": "INBOX", + "imap_sent": "Sent", + "smtp_host": settings.smtp_host, + "smtp_port": settings.smtp_port, + "smtp_username": settings.smtp_username, + "smtp_password": settings.smtp_password, + "smtp_use_tls": settings.smtp_use_tls, + "sync_inbound": True, + "allow_send": True, + } + ] + + return [] + + +def account_by_key(key: str | None) -> dict | None: + k = (key or "").strip().lower() + if not k: + return None + for acc in get_mail_accounts(): + if acc["key"] == k: + return acc + return None + + +def account_by_email(email_addr: str | None) -> dict | None: + e = (email_addr or "").strip().lower() + if not e: + return None + for acc in get_mail_accounts(): + if acc["email"] == e: + return acc + return None diff --git a/backend/crm/media_router.py b/backend/crm/media_router.py new file mode 100644 index 0000000..12fb46f --- /dev/null +++ b/backend/crm/media_router.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Depends, Query +from typing import Optional + +from auth.models import TokenPayload +from auth.dependencies import require_permission +from crm.models import MediaCreate, MediaInDB, MediaListResponse +from crm import service + +router = APIRouter(prefix="/api/crm/media", tags=["crm-media"]) + + +@router.get("", response_model=MediaListResponse) +async def list_media( + customer_id: Optional[str] = Query(None), + order_id: Optional[str] = Query(None), + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + items = await service.list_media(customer_id=customer_id, order_id=order_id) + return MediaListResponse(items=items, total=len(items)) + + +@router.post("", response_model=MediaInDB, status_code=201) +async def create_media( + body: MediaCreate, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + return await service.create_media(body) + + +@router.delete("/{media_id}", status_code=204) +async def delete_media( + media_id: str, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + await service.delete_media(media_id) diff --git a/backend/crm/models.py b/backend/crm/models.py new file mode 100644 index 0000000..06fdb15 --- /dev/null +++ b/backend/crm/models.py @@ -0,0 +1,353 @@ +from enum import Enum +from typing import List, Optional +from pydantic import BaseModel + + +class ProductCategory(str, Enum): + controller = "controller" + striker = "striker" + clock = "clock" + part = "part" + repair_service = "repair_service" + + +class CostLineItem(BaseModel): + name: str + quantity: float = 1 + price: float = 0.0 + + +class ProductCosts(BaseModel): + labor_hours: Optional[float] = None + labor_rate: Optional[float] = None + items: List[CostLineItem] = [] + total: Optional[float] = None + + +class ProductStock(BaseModel): + on_hand: int = 0 + reserved: int = 0 + available: int = 0 + + +class ProductCreate(BaseModel): + name: str + sku: Optional[str] = None + category: ProductCategory + description: Optional[str] = None + price: float + currency: str = "EUR" + costs: Optional[ProductCosts] = None + stock: Optional[ProductStock] = None + active: bool = True + status: str = "active" # active | discontinued | planned + photo_url: Optional[str] = None + + +class ProductUpdate(BaseModel): + name: Optional[str] = None + sku: Optional[str] = None + category: Optional[ProductCategory] = None + description: Optional[str] = None + price: Optional[float] = None + currency: Optional[str] = None + costs: Optional[ProductCosts] = None + stock: Optional[ProductStock] = None + active: Optional[bool] = None + status: Optional[str] = None + photo_url: Optional[str] = None + + +class ProductInDB(ProductCreate): + id: str + created_at: str + updated_at: str + + +class ProductListResponse(BaseModel): + products: List[ProductInDB] + total: int + + +# ── Customers ──────────────────────────────────────────────────────────────── + +class ContactType(str, Enum): + email = "email" + phone = "phone" + whatsapp = "whatsapp" + other = "other" + + +class CustomerContact(BaseModel): + type: ContactType + label: str + value: str + primary: bool = False + + +class CustomerNote(BaseModel): + text: str + by: str + at: str + + +class OwnedItemType(str, Enum): + console_device = "console_device" + product = "product" + freetext = "freetext" + + +class OwnedItem(BaseModel): + type: OwnedItemType + # console_device fields + device_id: Optional[str] = None + label: Optional[str] = None + # product fields + product_id: Optional[str] = None + product_name: Optional[str] = None + quantity: Optional[int] = None + serial_numbers: Optional[List[str]] = None + # freetext fields + description: Optional[str] = None + serial_number: Optional[str] = None + notes: Optional[str] = None + + +class CustomerLocation(BaseModel): + city: Optional[str] = None + country: Optional[str] = None + region: Optional[str] = None + + +class CustomerCreate(BaseModel): + title: Optional[str] = None + name: str + surname: Optional[str] = None + organization: Optional[str] = None + contacts: List[CustomerContact] = [] + notes: List[CustomerNote] = [] + location: Optional[CustomerLocation] = None + language: str = "el" + tags: List[str] = [] + owned_items: List[OwnedItem] = [] + linked_user_ids: List[str] = [] + nextcloud_folder: Optional[str] = None + folder_id: Optional[str] = None # Human-readable Nextcloud folder name, e.g. "saint-john-corfu" + + +class CustomerUpdate(BaseModel): + title: Optional[str] = None + name: Optional[str] = None + surname: Optional[str] = None + organization: Optional[str] = None + contacts: Optional[List[CustomerContact]] = None + notes: Optional[List[CustomerNote]] = None + location: Optional[CustomerLocation] = None + language: Optional[str] = None + tags: Optional[List[str]] = None + owned_items: Optional[List[OwnedItem]] = None + linked_user_ids: Optional[List[str]] = None + nextcloud_folder: Optional[str] = None + # folder_id intentionally excluded from update — set once at creation + + +class CustomerInDB(CustomerCreate): + id: str + created_at: str + updated_at: str + + +class CustomerListResponse(BaseModel): + customers: List[CustomerInDB] + total: int + + +# ── Orders ─────────────────────────────────────────────────────────────────── + +class OrderStatus(str, Enum): + draft = "draft" + confirmed = "confirmed" + in_production = "in_production" + shipped = "shipped" + delivered = "delivered" + cancelled = "cancelled" + + +class PaymentStatus(str, Enum): + pending = "pending" + partial = "partial" + paid = "paid" + + +class OrderDiscount(BaseModel): + type: str # "percentage" | "fixed" + value: float = 0 + reason: Optional[str] = None + + +class OrderShipping(BaseModel): + method: Optional[str] = None + tracking_number: Optional[str] = None + carrier: Optional[str] = None + shipped_at: Optional[str] = None + delivered_at: Optional[str] = None + destination: Optional[str] = None + + +class OrderItem(BaseModel): + type: str # console_device | product | freetext + product_id: Optional[str] = None + product_name: Optional[str] = None + description: Optional[str] = None + quantity: int = 1 + unit_price: float = 0.0 + serial_numbers: List[str] = [] + + +class OrderCreate(BaseModel): + customer_id: str + order_number: Optional[str] = None + status: OrderStatus = OrderStatus.draft + items: List[OrderItem] = [] + subtotal: float = 0 + discount: Optional[OrderDiscount] = None + total_price: float = 0 + currency: str = "EUR" + shipping: Optional[OrderShipping] = None + payment_status: PaymentStatus = PaymentStatus.pending + invoice_path: Optional[str] = None + notes: Optional[str] = None + + +class OrderUpdate(BaseModel): + customer_id: Optional[str] = None + order_number: Optional[str] = None + status: Optional[OrderStatus] = None + items: Optional[List[OrderItem]] = None + subtotal: Optional[float] = None + discount: Optional[OrderDiscount] = None + total_price: Optional[float] = None + currency: Optional[str] = None + shipping: Optional[OrderShipping] = None + payment_status: Optional[PaymentStatus] = None + invoice_path: Optional[str] = None + notes: Optional[str] = None + + +class OrderInDB(OrderCreate): + id: str + created_at: str + updated_at: str + + +class OrderListResponse(BaseModel): + orders: List[OrderInDB] + total: int + + +# ── Comms Log ───────────────────────────────────────────────────────────────── + +class CommType(str, Enum): + email = "email" + whatsapp = "whatsapp" + call = "call" + sms = "sms" + note = "note" + in_person = "in_person" + + +class CommDirection(str, Enum): + inbound = "inbound" + outbound = "outbound" + internal = "internal" + + +class CommAttachment(BaseModel): + filename: str + nextcloud_path: Optional[str] = None + content_type: Optional[str] = None + size: Optional[int] = None + + +class CommCreate(BaseModel): + customer_id: Optional[str] = None + type: CommType + mail_account: Optional[str] = None + direction: CommDirection + subject: Optional[str] = None + body: Optional[str] = None + body_html: Optional[str] = None + attachments: List[CommAttachment] = [] + ext_message_id: Optional[str] = None + from_addr: Optional[str] = None + to_addrs: Optional[List[str]] = None + logged_by: Optional[str] = None + occurred_at: Optional[str] = None # defaults to now if not provided + + +class CommUpdate(BaseModel): + subject: Optional[str] = None + body: Optional[str] = None + occurred_at: Optional[str] = None + + +class CommInDB(BaseModel): + id: str + customer_id: Optional[str] = None + type: CommType + mail_account: Optional[str] = None + direction: CommDirection + subject: Optional[str] = None + body: Optional[str] = None + body_html: Optional[str] = None + attachments: List[CommAttachment] = [] + ext_message_id: Optional[str] = None + from_addr: Optional[str] = None + to_addrs: Optional[List[str]] = None + logged_by: Optional[str] = None + occurred_at: str + created_at: str + is_important: bool = False + is_read: bool = False + + +class CommListResponse(BaseModel): + entries: List[CommInDB] + total: int + + +# ── Media ───────────────────────────────────────────────────────────────────── + +class MediaDirection(str, Enum): + received = "received" + sent = "sent" + internal = "internal" + + +class MediaCreate(BaseModel): + customer_id: Optional[str] = None + order_id: Optional[str] = None + filename: str + nextcloud_path: str + mime_type: Optional[str] = None + direction: Optional[MediaDirection] = None + tags: List[str] = [] + uploaded_by: Optional[str] = None + + +class MediaInDB(BaseModel): + id: str + customer_id: Optional[str] = None + order_id: Optional[str] = None + filename: str + nextcloud_path: str + mime_type: Optional[str] = None + direction: Optional[MediaDirection] = None + tags: List[str] = [] + uploaded_by: Optional[str] = None + created_at: str + + +class MediaListResponse(BaseModel): + items: List[MediaInDB] + total: int diff --git a/backend/crm/nextcloud.py b/backend/crm/nextcloud.py new file mode 100644 index 0000000..da67e0c --- /dev/null +++ b/backend/crm/nextcloud.py @@ -0,0 +1,314 @@ +""" +Nextcloud WebDAV client. + +All paths passed to these functions are relative to `settings.nextcloud_base_path`. +The full WebDAV URL is: + {nextcloud_url}/remote.php/dav/files/{username}/{base_path}/{relative_path} +""" +import xml.etree.ElementTree as ET +from typing import List +from urllib.parse import unquote + +import httpx +from fastapi import HTTPException + +from config import settings + +DAV_NS = "DAV:" + +# Default timeout for all Nextcloud WebDAV requests (seconds) +_TIMEOUT = 60.0 + +# Shared async client — reuses TCP connections across requests so Nextcloud +# doesn't see rapid connection bursts that trigger brute-force throttling. +_http_client: httpx.AsyncClient | None = None + + +def _get_client() -> httpx.AsyncClient: + global _http_client + if _http_client is None or _http_client.is_closed: + _http_client = httpx.AsyncClient( + timeout=_TIMEOUT, + follow_redirects=True, + headers={"User-Agent": "BellSystems-CP/1.0"}, + ) + return _http_client + + +async def close_client() -> None: + """Close the shared HTTP client. Call this on application shutdown.""" + global _http_client + if _http_client and not _http_client.is_closed: + await _http_client.aclose() + _http_client = None + + +async def keepalive_ping() -> None: + """ + Send a lightweight PROPFIND Depth:0 to the Nextcloud base folder to keep + the TCP connection alive. Safe to call even if Nextcloud is not configured. + """ + if not settings.nextcloud_url: + return + try: + url = _base_url() + client = _get_client() + await client.request( + "PROPFIND", + url, + auth=_auth(), + headers={"Depth": "0", "Content-Type": "application/xml"}, + content=_PROPFIND_BODY, + ) + except Exception as e: + print(f"[NEXTCLOUD KEEPALIVE] ping failed: {e}") + + +def _dav_user() -> str: + """The username used in the WebDAV URL path (may differ from the login username).""" + return settings.nextcloud_dav_user or settings.nextcloud_username + + +def _base_url() -> str: + if not settings.nextcloud_url: + raise HTTPException(status_code=503, detail="Nextcloud not configured") + return ( + f"{settings.nextcloud_url.rstrip('/')}" + f"/remote.php/dav/files/{_dav_user()}" + f"/{settings.nextcloud_base_path}" + ) + + +def _auth() -> tuple[str, str]: + return (settings.nextcloud_username, settings.nextcloud_password) + + +def _full_url(relative_path: str) -> str: + """Build full WebDAV URL for a relative path.""" + path = relative_path.strip("/") + base = _base_url() + return f"{base}/{path}" if path else base + + +def _parse_propfind(xml_bytes: bytes, base_path_prefix: str) -> List[dict]: + """ + Parse a PROPFIND XML response. + Returns list of file/folder entries, skipping the root itself. + """ + root = ET.fromstring(xml_bytes) + results = [] + + # The prefix we need to strip from D:href to get the relative path back + # href looks like: /remote.php/dav/files/user/BellSystems/Console/customers/abc/ + dav_prefix = ( + f"/remote.php/dav/files/{_dav_user()}" + f"/{settings.nextcloud_base_path}/" + ) + + for response in root.findall(f"{{{DAV_NS}}}response"): + href_el = response.find(f"{{{DAV_NS}}}href") + if href_el is None: + continue + href = unquote(href_el.text or "") + + # Strip DAV prefix to get relative path within base_path + if href.startswith(dav_prefix): + rel = href[len(dav_prefix):].rstrip("/") + else: + rel = href + + # Skip the folder itself (the root of the PROPFIND request) + if rel == base_path_prefix.strip("/"): + continue + + propstat = response.find(f"{{{DAV_NS}}}propstat") + if propstat is None: + continue + prop = propstat.find(f"{{{DAV_NS}}}prop") + if prop is None: + continue + + # is_dir: resourcetype contains D:collection + resource_type = prop.find(f"{{{DAV_NS}}}resourcetype") + is_dir = resource_type is not None and resource_type.find(f"{{{DAV_NS}}}collection") is not None + + content_type_el = prop.find(f"{{{DAV_NS}}}getcontenttype") + mime_type = content_type_el.text if content_type_el is not None else ( + "inode/directory" if is_dir else "application/octet-stream" + ) + + size_el = prop.find(f"{{{DAV_NS}}}getcontentlength") + size = int(size_el.text) if size_el is not None and size_el.text else 0 + + modified_el = prop.find(f"{{{DAV_NS}}}getlastmodified") + last_modified = modified_el.text if modified_el is not None else None + + filename = rel.split("/")[-1] if rel else "" + + results.append({ + "filename": filename, + "path": rel, + "mime_type": mime_type, + "size": size, + "last_modified": last_modified, + "is_dir": is_dir, + }) + + return results + + +async def ensure_folder(relative_path: str) -> None: + """ + Create a folder (and all parents) in Nextcloud via MKCOL. + Includes the base_path segments so the full hierarchy is created from scratch. + Silently succeeds if folders already exist. + """ + # Build the complete path list: base_path segments + relative_path segments + base_parts = settings.nextcloud_base_path.strip("/").split("/") + rel_parts = relative_path.strip("/").split("/") if relative_path.strip("/") else [] + all_parts = base_parts + rel_parts + + dav_root = f"{settings.nextcloud_url.rstrip('/')}/remote.php/dav/files/{_dav_user()}" + client = _get_client() + built = "" + for part in all_parts: + built = f"{built}/{part}" if built else part + url = f"{dav_root}/{built}" + resp = await client.request("MKCOL", url, auth=_auth()) + # 201 = created, 405/409 = already exists — both are fine + if resp.status_code not in (201, 405, 409): + raise HTTPException( + status_code=502, + detail=f"Failed to create Nextcloud folder '{built}': {resp.status_code}", + ) + + +async def write_info_file(customer_folder: str, customer_name: str, customer_id: str) -> None: + """Write a _info.txt stub into a new customer folder for human browsability.""" + content = f"Customer: {customer_name}\nID: {customer_id}\n" + await upload_file( + f"{customer_folder}/_info.txt", + content.encode("utf-8"), + "text/plain", + ) + + +_PROPFIND_BODY = b""" + + + + + + + +""" + + +async def list_folder(relative_path: str) -> List[dict]: + """ + PROPFIND at depth=1 to list a folder's immediate children. + relative_path is relative to nextcloud_base_path. + """ + url = _full_url(relative_path) + client = _get_client() + resp = await client.request( + "PROPFIND", + url, + auth=_auth(), + headers={"Depth": "1", "Content-Type": "application/xml"}, + content=_PROPFIND_BODY, + ) + if resp.status_code == 404: + return [] + if resp.status_code not in (207, 200): + raise HTTPException(status_code=502, detail=f"Nextcloud PROPFIND failed: {resp.status_code}") + return _parse_propfind(resp.content, relative_path) + + +async def list_folder_recursive(relative_path: str) -> List[dict]: + """ + Recursively list ALL files under a folder (any depth). + Tries Depth:infinity first (single call). Falls back to manual recursion + via Depth:1 if the server returns 403/400 (some servers disable infinity). + Returns only file entries (is_dir=False). + """ + url = _full_url(relative_path) + client = _get_client() + resp = await client.request( + "PROPFIND", + url, + auth=_auth(), + headers={"Depth": "infinity", "Content-Type": "application/xml"}, + content=_PROPFIND_BODY, + ) + + if resp.status_code in (207, 200): + all_items = _parse_propfind(resp.content, relative_path) + return [item for item in all_items if not item["is_dir"]] + + # Depth:infinity not supported — fall back to recursive Depth:1 + if resp.status_code in (403, 400, 412): + return await _list_recursive_fallback(relative_path) + + if resp.status_code == 404: + return [] + + raise HTTPException(status_code=502, detail=f"Nextcloud PROPFIND failed: {resp.status_code}") + + +async def _list_recursive_fallback(relative_path: str) -> List[dict]: + """Manually recurse via Depth:1 calls when Depth:infinity is blocked.""" + items = await list_folder(relative_path) + files = [] + dirs = [] + for item in items: + if item["is_dir"]: + dirs.append(item["path"]) + else: + files.append(item) + for dir_path in dirs: + child_files = await _list_recursive_fallback(dir_path) + files.extend(child_files) + return files + + +async def upload_file(relative_path: str, content: bytes, mime_type: str) -> str: + """ + PUT a file to Nextcloud. Returns the relative_path on success. + relative_path includes filename, e.g. "customers/abc123/media/photo.jpg" + """ + url = _full_url(relative_path) + client = _get_client() + resp = await client.put( + url, + auth=_auth(), + content=content, + headers={"Content-Type": mime_type}, + ) + if resp.status_code not in (200, 201, 204): + raise HTTPException(status_code=502, detail=f"Nextcloud upload failed: {resp.status_code}") + return relative_path + + +async def download_file(relative_path: str) -> tuple[bytes, str]: + """ + GET a file from Nextcloud. Returns (bytes, mime_type). + """ + url = _full_url(relative_path) + client = _get_client() + resp = await client.get(url, auth=_auth()) + if resp.status_code == 404: + raise HTTPException(status_code=404, detail="File not found in Nextcloud") + if resp.status_code != 200: + raise HTTPException(status_code=502, detail=f"Nextcloud download failed: {resp.status_code}") + mime = resp.headers.get("content-type", "application/octet-stream").split(";")[0].strip() + return resp.content, mime + + +async def delete_file(relative_path: str) -> None: + """DELETE a file from Nextcloud.""" + url = _full_url(relative_path) + client = _get_client() + resp = await client.request("DELETE", url, auth=_auth()) + if resp.status_code not in (200, 204, 404): + raise HTTPException(status_code=502, detail=f"Nextcloud delete failed: {resp.status_code}") diff --git a/backend/crm/nextcloud_router.py b/backend/crm/nextcloud_router.py new file mode 100644 index 0000000..b1e8876 --- /dev/null +++ b/backend/crm/nextcloud_router.py @@ -0,0 +1,305 @@ +""" +Nextcloud WebDAV proxy endpoints. + +Folder convention (all paths relative to nextcloud_base_path = BellSystems/Console): + customers/{folder_id}/media/ + customers/{folder_id}/documents/ + customers/{folder_id}/sent/ + customers/{folder_id}/received/ + +folder_id = customer.folder_id if set, else customer.id (legacy fallback). +""" +from fastapi import APIRouter, Depends, Query, UploadFile, File, Form, Response, HTTPException, Request +from typing import Optional + +from jose import JWTError +from auth.models import TokenPayload +from auth.dependencies import require_permission +from auth.utils import decode_access_token +from crm import nextcloud, service +from crm.models import MediaCreate, MediaDirection + +router = APIRouter(prefix="/api/crm/nextcloud", tags=["crm-nextcloud"]) + +DIRECTION_MAP = { + "sent": MediaDirection.sent, + "received": MediaDirection.received, + "internal": MediaDirection.internal, + "media": MediaDirection.internal, + "documents": MediaDirection.internal, +} + + +@router.get("/browse") +async def browse( + path: str = Query(..., description="Path relative to nextcloud_base_path"), + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + """List immediate children of a Nextcloud folder.""" + items = await nextcloud.list_folder(path) + return {"path": path, "items": items} + + +@router.get("/browse-all") +async def browse_all( + customer_id: str = Query(..., description="Customer ID"), + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + """ + Recursively list ALL files for a customer across all subfolders and any depth. + Uses Depth:infinity (one WebDAV call) with automatic fallback to recursive Depth:1. + Each file item includes a 'subfolder' key derived from its path. + """ + customer = service.get_customer(customer_id) + nc_path = service.get_customer_nc_path(customer) + base = f"customers/{nc_path}" + + all_files = await nextcloud.list_folder_recursive(base) + + # Tag each file with the top-level subfolder it lives under + for item in all_files: + parts = item["path"].split("/") + # path looks like: customers/{nc_path}/{subfolder}/[...]/filename + # parts[0]=customers, parts[1]={nc_path}, parts[2]={subfolder} + item["subfolder"] = parts[2] if len(parts) > 2 else "other" + + return {"items": all_files} + + +@router.get("/file") +async def proxy_file( + request: Request, + path: str = Query(..., description="Path relative to nextcloud_base_path"), + token: Optional[str] = Query(None, description="JWT token for browser-native requests (img src, video src, a href) that cannot send an Authorization header"), +): + """ + Stream a file from Nextcloud through the backend (proxy). + Supports HTTP Range requests so videos can be seeked and start playing immediately. + Accepts auth via Authorization: Bearer header OR ?token= query param. + """ + if token is None: + raise HTTPException(status_code=403, detail="Not authenticated") + try: + decode_access_token(token) + except (JWTError, KeyError): + raise HTTPException(status_code=403, detail="Invalid token") + + content, mime_type = await nextcloud.download_file(path) + total = len(content) + + range_header = request.headers.get("range") + if range_header and range_header.startswith("bytes="): + # Parse "bytes=start-end" + try: + range_spec = range_header[6:] + start_str, _, end_str = range_spec.partition("-") + start = int(start_str) if start_str else 0 + end = int(end_str) if end_str else total - 1 + end = min(end, total - 1) + chunk = content[start:end + 1] + headers = { + "Content-Range": f"bytes {start}-{end}/{total}", + "Accept-Ranges": "bytes", + "Content-Length": str(len(chunk)), + "Content-Type": mime_type, + } + return Response(content=chunk, status_code=206, headers=headers, media_type=mime_type) + except (ValueError, IndexError): + pass + + return Response( + content=content, + media_type=mime_type, + headers={"Accept-Ranges": "bytes", "Content-Length": str(total)}, + ) + + +@router.put("/file-put") +async def put_file( + request: Request, + path: str = Query(..., description="Path relative to nextcloud_base_path"), + token: Optional[str] = Query(None), +): + """ + Overwrite a file in Nextcloud with a new body (used for TXT in-browser editing). + Auth via ?token= query param (same pattern as /file GET). + """ + if token is None: + raise HTTPException(status_code=403, detail="Not authenticated") + try: + decode_access_token(token) + except (JWTError, KeyError): + raise HTTPException(status_code=403, detail="Invalid token") + + body = await request.body() + content_type = request.headers.get("content-type", "text/plain") + await nextcloud.upload_file(path, body, content_type) + return {"updated": path} + + +@router.post("/upload") +async def upload_file( + file: UploadFile = File(...), + customer_id: str = Form(...), + subfolder: str = Form("media"), # "media" | "documents" | "sent" | "received" + direction: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + """ + Upload a file to the customer's Nextcloud folder and record it in crm_media. + Uses the customer's folder_id as the NC path (falls back to UUID for legacy records). + """ + customer = service.get_customer(customer_id) + nc_path = service.get_customer_nc_path(customer) + + target_folder = f"customers/{nc_path}/{subfolder}" + file_path = f"{target_folder}/{file.filename}" + + # Ensure the target subfolder exists (idempotent, fast for existing folders) + await nextcloud.ensure_folder(target_folder) + + # Read and upload + content = await file.read() + mime_type = file.content_type or "application/octet-stream" + await nextcloud.upload_file(file_path, content, mime_type) + + # Resolve direction + resolved_direction = None + if direction: + try: + resolved_direction = MediaDirection(direction) + except ValueError: + resolved_direction = DIRECTION_MAP.get(subfolder, MediaDirection.internal) + else: + resolved_direction = DIRECTION_MAP.get(subfolder, MediaDirection.internal) + + # Save metadata record + tag_list = [t.strip() for t in tags.split(",")] if tags else [] + media_record = await service.create_media(MediaCreate( + customer_id=customer_id, + filename=file.filename, + nextcloud_path=file_path, + mime_type=mime_type, + direction=resolved_direction, + tags=tag_list, + uploaded_by=_user.name, + )) + + return media_record + + +@router.delete("/file") +async def delete_file( + path: str = Query(..., description="Path relative to nextcloud_base_path"), + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + """Delete a file from Nextcloud and remove the matching crm_media record if found.""" + await nextcloud.delete_file(path) + + # Best-effort: delete the DB record if one matches this path + media_list = await service.list_media() + for m in media_list: + if m.nextcloud_path == path: + try: + await service.delete_media(m.id) + except Exception: + pass + break + + return {"deleted": path} + + +@router.post("/init-customer-folder") +async def init_customer_folder( + customer_id: str = Form(...), + customer_name: str = Form(...), + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + """ + Create the standard folder structure for a customer in Nextcloud + and write an _info.txt stub for human readability. + """ + customer = service.get_customer(customer_id) + nc_path = service.get_customer_nc_path(customer) + base = f"customers/{nc_path}" + for sub in ("media", "documents", "sent", "received"): + await nextcloud.ensure_folder(f"{base}/{sub}") + await nextcloud.write_info_file(base, customer_name, customer_id) + return {"initialized": base} + + +@router.post("/sync") +async def sync_nextcloud_files( + customer_id: str = Form(...), + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + """ + Scan the customer's Nextcloud folder and register any files not yet tracked in the DB. + Returns counts of newly synced and skipped (already tracked) files. + """ + customer = service.get_customer(customer_id) + nc_path = service.get_customer_nc_path(customer) + base = f"customers/{nc_path}" + + # Collect all NC files recursively (handles nested folders at any depth) + all_nc_files = await nextcloud.list_folder_recursive(base) + for item in all_nc_files: + parts = item["path"].split("/") + item["_subfolder"] = parts[2] if len(parts) > 2 else "media" + + # Get existing DB records for this customer + existing = await service.list_media(customer_id=customer_id) + tracked_paths = {m.nextcloud_path for m in existing} + + synced = 0 + skipped = 0 + for f in all_nc_files: + if f["path"] in tracked_paths: + skipped += 1 + continue + sub = f["_subfolder"] + direction = DIRECTION_MAP.get(sub, MediaDirection.internal) + await service.create_media(MediaCreate( + customer_id=customer_id, + filename=f["filename"], + nextcloud_path=f["path"], + mime_type=f.get("mime_type") or "application/octet-stream", + direction=direction, + tags=[], + uploaded_by="nextcloud-sync", + )) + synced += 1 + + return {"synced": synced, "skipped": skipped} + + +@router.post("/untrack-deleted") +async def untrack_deleted_files( + customer_id: str = Form(...), + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + """ + Remove DB records for files that no longer exist in Nextcloud. + Returns count of untracked records. + """ + customer = service.get_customer(customer_id) + nc_path = service.get_customer_nc_path(customer) + base = f"customers/{nc_path}" + + # Collect all NC file paths recursively + all_nc_files = await nextcloud.list_folder_recursive(base) + nc_paths = {item["path"] for item in all_nc_files} + + # Find DB records whose NC path no longer exists + existing = await service.list_media(customer_id=customer_id) + untracked = 0 + for m in existing: + if m.nextcloud_path and m.nextcloud_path not in nc_paths: + try: + await service.delete_media(m.id) + untracked += 1 + except Exception: + pass + + return {"untracked": untracked} diff --git a/backend/crm/orders_router.py b/backend/crm/orders_router.py new file mode 100644 index 0000000..a7e95ea --- /dev/null +++ b/backend/crm/orders_router.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, Query +from typing import Optional + +from auth.models import TokenPayload +from auth.dependencies import require_permission +from crm.models import OrderCreate, OrderUpdate, OrderInDB, OrderListResponse +from crm import service + +router = APIRouter(prefix="/api/crm/orders", tags=["crm-orders"]) + + +@router.get("", response_model=OrderListResponse) +def list_orders( + customer_id: Optional[str] = Query(None), + status: Optional[str] = Query(None), + payment_status: Optional[str] = Query(None), + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + orders = service.list_orders( + customer_id=customer_id, + status=status, + payment_status=payment_status, + ) + return OrderListResponse(orders=orders, total=len(orders)) + + +@router.get("/{order_id}", response_model=OrderInDB) +def get_order( + order_id: str, + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + return service.get_order(order_id) + + +@router.post("", response_model=OrderInDB, status_code=201) +def create_order( + body: OrderCreate, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + return service.create_order(body) + + +@router.put("/{order_id}", response_model=OrderInDB) +def update_order( + order_id: str, + body: OrderUpdate, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + return service.update_order(order_id, body) + + +@router.delete("/{order_id}", status_code=204) +def delete_order( + order_id: str, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + service.delete_order(order_id) diff --git a/backend/crm/quotation_models.py b/backend/crm/quotation_models.py new file mode 100644 index 0000000..74e54f3 --- /dev/null +++ b/backend/crm/quotation_models.py @@ -0,0 +1,141 @@ +from enum import Enum +from typing import Any, Dict, List, Optional +from pydantic import BaseModel + + +class QuotationStatus(str, Enum): + draft = "draft" + sent = "sent" + accepted = "accepted" + rejected = "rejected" + + +class QuotationItemCreate(BaseModel): + product_id: Optional[str] = None + description: Optional[str] = None + unit_type: str = "pcs" # pcs / kg / m + unit_cost: float = 0.0 + discount_percent: float = 0.0 + quantity: float = 1.0 + vat_percent: float = 24.0 + sort_order: int = 0 + + +class QuotationItemInDB(QuotationItemCreate): + id: str + quotation_id: str + line_total: float = 0.0 + + +class QuotationCreate(BaseModel): + customer_id: str + title: Optional[str] = None + subtitle: Optional[str] = None + language: str = "en" # en / gr + order_type: Optional[str] = None + shipping_method: Optional[str] = None + estimated_shipping_date: Optional[str] = None + global_discount_label: Optional[str] = None + global_discount_percent: float = 0.0 + shipping_cost: float = 0.0 + shipping_cost_discount: float = 0.0 + install_cost: float = 0.0 + install_cost_discount: float = 0.0 + extras_label: Optional[str] = None + extras_cost: float = 0.0 + comments: List[str] = [] + quick_notes: Optional[Dict[str, Any]] = None + items: List[QuotationItemCreate] = [] + # Client override fields (for this quotation only; customer record is not modified) + client_org: Optional[str] = None + client_name: Optional[str] = None + client_location: Optional[str] = None + client_phone: Optional[str] = None + client_email: Optional[str] = None + + +class QuotationUpdate(BaseModel): + title: Optional[str] = None + subtitle: Optional[str] = None + language: Optional[str] = None + status: Optional[QuotationStatus] = None + order_type: Optional[str] = None + shipping_method: Optional[str] = None + estimated_shipping_date: Optional[str] = None + global_discount_label: Optional[str] = None + global_discount_percent: Optional[float] = None + shipping_cost: Optional[float] = None + shipping_cost_discount: Optional[float] = None + install_cost: Optional[float] = None + install_cost_discount: Optional[float] = None + extras_label: Optional[str] = None + extras_cost: Optional[float] = None + comments: Optional[List[str]] = None + quick_notes: Optional[Dict[str, Any]] = None + items: Optional[List[QuotationItemCreate]] = None + # Client override fields + client_org: Optional[str] = None + client_name: Optional[str] = None + client_location: Optional[str] = None + client_phone: Optional[str] = None + client_email: Optional[str] = None + + +class QuotationInDB(BaseModel): + id: str + quotation_number: str + customer_id: str + title: Optional[str] = None + subtitle: Optional[str] = None + language: str = "en" + status: QuotationStatus = QuotationStatus.draft + order_type: Optional[str] = None + shipping_method: Optional[str] = None + estimated_shipping_date: Optional[str] = None + global_discount_label: Optional[str] = None + global_discount_percent: float = 0.0 + shipping_cost: float = 0.0 + shipping_cost_discount: float = 0.0 + install_cost: float = 0.0 + install_cost_discount: float = 0.0 + extras_label: Optional[str] = None + extras_cost: float = 0.0 + comments: List[str] = [] + quick_notes: Dict[str, Any] = {} + subtotal_before_discount: float = 0.0 + global_discount_amount: float = 0.0 + new_subtotal: float = 0.0 + vat_amount: float = 0.0 + final_total: float = 0.0 + nextcloud_pdf_path: Optional[str] = None + nextcloud_pdf_url: Optional[str] = None + created_at: str + updated_at: str + items: List[QuotationItemInDB] = [] + # Client override fields + client_org: Optional[str] = None + client_name: Optional[str] = None + client_location: Optional[str] = None + client_phone: Optional[str] = None + client_email: Optional[str] = None + + +class QuotationListItem(BaseModel): + id: str + quotation_number: str + title: Optional[str] = None + customer_id: str + status: QuotationStatus + final_total: float + created_at: str + updated_at: str + nextcloud_pdf_url: Optional[str] = None + + +class QuotationListResponse(BaseModel): + quotations: List[QuotationListItem] + total: int + + +class NextNumberResponse(BaseModel): + next_number: str diff --git a/backend/crm/quotations_router.py b/backend/crm/quotations_router.py new file mode 100644 index 0000000..fc23271 --- /dev/null +++ b/backend/crm/quotations_router.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +from typing import Optional +import io + +from auth.dependencies import require_permission +from auth.models import TokenPayload +from crm.quotation_models import ( + NextNumberResponse, + QuotationCreate, + QuotationInDB, + QuotationListResponse, + QuotationUpdate, +) +from crm import quotations_service as svc + +router = APIRouter(prefix="/api/crm/quotations", tags=["crm-quotations"]) + + +# IMPORTANT: Static paths must come BEFORE /{id} to avoid route collision in FastAPI + +@router.get("/next-number", response_model=NextNumberResponse) +async def get_next_number( + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + """Returns the next available quotation number (preview only — does not commit).""" + next_num = await svc.get_next_number() + return NextNumberResponse(next_number=next_num) + + +@router.get("/customer/{customer_id}", response_model=QuotationListResponse) +async def list_quotations_for_customer( + customer_id: str, + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + quotations = await svc.list_quotations(customer_id) + return QuotationListResponse(quotations=quotations, total=len(quotations)) + + +@router.get("/{quotation_id}/pdf") +async def proxy_quotation_pdf( + quotation_id: str, + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + """Proxy the quotation PDF from Nextcloud to bypass browser cookie restrictions.""" + pdf_bytes = await svc.get_quotation_pdf_bytes(quotation_id) + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": "inline"}, + ) + + +@router.get("/{quotation_id}", response_model=QuotationInDB) +async def get_quotation( + quotation_id: str, + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + return await svc.get_quotation(quotation_id) + + +@router.post("", response_model=QuotationInDB, status_code=201) +async def create_quotation( + body: QuotationCreate, + generate_pdf: bool = Query(False), + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + """ + Create a quotation. Pass ?generate_pdf=true to immediately generate and upload the PDF. + """ + return await svc.create_quotation(body, generate_pdf=generate_pdf) + + +@router.put("/{quotation_id}", response_model=QuotationInDB) +async def update_quotation( + quotation_id: str, + body: QuotationUpdate, + generate_pdf: bool = Query(False), + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + """ + Update a quotation. Pass ?generate_pdf=true to regenerate the PDF. + """ + return await svc.update_quotation(quotation_id, body, generate_pdf=generate_pdf) + + +@router.delete("/{quotation_id}", status_code=204) +async def delete_quotation( + quotation_id: str, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + await svc.delete_quotation(quotation_id) + + +@router.post("/{quotation_id}/regenerate-pdf", response_model=QuotationInDB) +async def regenerate_pdf( + quotation_id: str, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + """Force PDF regeneration and re-upload to Nextcloud.""" + return await svc.regenerate_pdf(quotation_id) diff --git a/backend/crm/quotations_service.py b/backend/crm/quotations_service.py new file mode 100644 index 0000000..3525595 --- /dev/null +++ b/backend/crm/quotations_service.py @@ -0,0 +1,494 @@ +import json +import logging +import os +import uuid +from datetime import datetime +from decimal import Decimal, ROUND_HALF_UP +from pathlib import Path +from typing import Optional + +from fastapi import HTTPException + +from crm import nextcloud +from crm.quotation_models import ( + QuotationCreate, + QuotationInDB, + QuotationItemCreate, + QuotationItemInDB, + QuotationListItem, + QuotationUpdate, +) +from crm.service import get_customer +from mqtt import database as mqtt_db + +logger = logging.getLogger(__name__) + +# Path to Jinja2 templates directory (relative to this file) +_TEMPLATES_DIR = Path(__file__).parent.parent / "templates" + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _d(value) -> Decimal: + """Convert to Decimal safely.""" + return Decimal(str(value if value is not None else 0)) + + +def _float(d: Decimal) -> float: + """Round Decimal to 2dp and return as float for storage.""" + return float(d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + + +def _calculate_totals( + items: list, + global_discount_percent: float, + shipping_cost: float, + shipping_cost_discount: float, + install_cost: float, + install_cost_discount: float, + extras_cost: float, +) -> dict: + """ + Calculate all monetary totals using Decimal arithmetic (ROUND_HALF_UP). + VAT is computed per-item from each item's vat_percent field. + Shipping and install costs carry 0% VAT. + Returns a dict of floats ready for DB storage. + """ + # Per-line totals and per-item VAT + item_totals = [] + item_vat = Decimal(0) + for item in items: + cost = _d(item.get("unit_cost", 0)) + qty = _d(item.get("quantity", 1)) + disc = _d(item.get("discount_percent", 0)) + net = cost * qty * (1 - disc / 100) + item_totals.append(net) + vat_pct = _d(item.get("vat_percent", 24)) + item_vat += net * (vat_pct / 100) + + # Shipping net (VAT = 0%) + ship_gross = _d(shipping_cost) + ship_disc = _d(shipping_cost_discount) + ship_net = ship_gross * (1 - ship_disc / 100) + + # Install net (VAT = 0%) + install_gross = _d(install_cost) + install_disc = _d(install_cost_discount) + install_net = install_gross * (1 - install_disc / 100) + + subtotal = sum(item_totals, Decimal(0)) + ship_net + install_net + + global_disc_pct = _d(global_discount_percent) + global_disc_amount = subtotal * (global_disc_pct / 100) + new_subtotal = subtotal - global_disc_amount + + # Global discount proportionally reduces VAT too + if subtotal > 0: + disc_ratio = new_subtotal / subtotal + vat_amount = item_vat * disc_ratio + else: + vat_amount = Decimal(0) + + extras = _d(extras_cost) + final_total = new_subtotal + vat_amount + extras + + return { + "subtotal_before_discount": _float(subtotal), + "global_discount_amount": _float(global_disc_amount), + "new_subtotal": _float(new_subtotal), + "vat_amount": _float(vat_amount), + "final_total": _float(final_total), + } + + +def _calc_line_total(item) -> float: + cost = _d(item.get("unit_cost", 0)) + qty = _d(item.get("quantity", 1)) + disc = _d(item.get("discount_percent", 0)) + return _float(cost * qty * (1 - disc / 100)) + + +async def _generate_quotation_number(db) -> str: + year = datetime.utcnow().year + prefix = f"QT-{year}-" + rows = await db.execute_fetchall( + "SELECT quotation_number FROM crm_quotations WHERE quotation_number LIKE ? ORDER BY quotation_number DESC LIMIT 1", + (f"{prefix}%",), + ) + if rows: + last_num = rows[0][0] # e.g. "QT-2026-012" + try: + seq = int(last_num[len(prefix):]) + 1 + except ValueError: + seq = 1 + else: + seq = 1 + return f"{prefix}{seq:03d}" + + +def _row_to_quotation(row: dict, items: list[dict]) -> QuotationInDB: + row = dict(row) + row["comments"] = json.loads(row.get("comments") or "[]") + row["quick_notes"] = json.loads(row.get("quick_notes") or "{}") + item_models = [QuotationItemInDB(**{k: v for k, v in i.items() if k in QuotationItemInDB.model_fields}) for i in items] + return QuotationInDB(**{k: v for k, v in row.items() if k in QuotationInDB.model_fields}, items=item_models) + + +def _row_to_list_item(row: dict) -> QuotationListItem: + return QuotationListItem(**{k: v for k, v in dict(row).items() if k in QuotationListItem.model_fields}) + + +async def _fetch_items(db, quotation_id: str) -> list[dict]: + rows = await db.execute_fetchall( + "SELECT * FROM crm_quotation_items WHERE quotation_id = ? ORDER BY sort_order ASC", + (quotation_id,), + ) + return [dict(r) for r in rows] + + +# ── Public API ──────────────────────────────────────────────────────────────── + +async def get_next_number() -> str: + db = await mqtt_db.get_db() + return await _generate_quotation_number(db) + + +async def list_quotations(customer_id: str) -> list[QuotationListItem]: + db = await mqtt_db.get_db() + rows = await db.execute_fetchall( + "SELECT id, quotation_number, title, customer_id, status, final_total, created_at, updated_at, nextcloud_pdf_url " + "FROM crm_quotations WHERE customer_id = ? ORDER BY created_at DESC", + (customer_id,), + ) + return [_row_to_list_item(dict(r)) for r in rows] + + +async def get_quotation(quotation_id: str) -> QuotationInDB: + db = await mqtt_db.get_db() + rows = await db.execute_fetchall( + "SELECT * FROM crm_quotations WHERE id = ?", (quotation_id,) + ) + if not rows: + raise HTTPException(status_code=404, detail="Quotation not found") + items = await _fetch_items(db, quotation_id) + return _row_to_quotation(dict(rows[0]), items) + + +async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) -> QuotationInDB: + db = await mqtt_db.get_db() + now = datetime.utcnow().isoformat() + qid = str(uuid.uuid4()) + quotation_number = await _generate_quotation_number(db) + + # Build items list for calculation + items_raw = [item.model_dump() for item in data.items] + + # Calculate per-item line totals + for item in items_raw: + item["line_total"] = _calc_line_total(item) + + totals = _calculate_totals( + items_raw, + data.global_discount_percent, + data.shipping_cost, + data.shipping_cost_discount, + data.install_cost, + data.install_cost_discount, + data.extras_cost, + ) + + comments_json = json.dumps(data.comments) + quick_notes_json = json.dumps(data.quick_notes or {}) + + await db.execute( + """INSERT INTO crm_quotations ( + id, quotation_number, title, subtitle, customer_id, + language, status, order_type, shipping_method, estimated_shipping_date, + global_discount_label, global_discount_percent, + shipping_cost, shipping_cost_discount, install_cost, install_cost_discount, + extras_label, extras_cost, comments, quick_notes, + subtotal_before_discount, global_discount_amount, new_subtotal, vat_amount, final_total, + nextcloud_pdf_path, nextcloud_pdf_url, + client_org, client_name, client_location, client_phone, client_email, + created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, + ?, 'draft', ?, ?, ?, + ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, ?, + NULL, NULL, + ?, ?, ?, ?, ?, + ?, ? + )""", + ( + qid, quotation_number, data.title, data.subtitle, data.customer_id, + data.language, data.order_type, data.shipping_method, data.estimated_shipping_date, + data.global_discount_label, data.global_discount_percent, + data.shipping_cost, data.shipping_cost_discount, data.install_cost, data.install_cost_discount, + data.extras_label, data.extras_cost, comments_json, quick_notes_json, + totals["subtotal_before_discount"], totals["global_discount_amount"], + totals["new_subtotal"], totals["vat_amount"], totals["final_total"], + data.client_org, data.client_name, data.client_location, data.client_phone, data.client_email, + now, now, + ), + ) + + # Insert items + for i, item in enumerate(items_raw): + item_id = str(uuid.uuid4()) + await db.execute( + """INSERT INTO crm_quotation_items + (id, quotation_id, product_id, description, unit_type, unit_cost, + discount_percent, quantity, vat_percent, line_total, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + item_id, qid, item.get("product_id"), item.get("description"), + item.get("unit_type", "pcs"), item.get("unit_cost", 0), + item.get("discount_percent", 0), item.get("quantity", 1), + item.get("vat_percent", 24), item["line_total"], item.get("sort_order", i), + ), + ) + + await db.commit() + + quotation = await get_quotation(qid) + + if generate_pdf: + quotation = await _do_generate_and_upload_pdf(quotation) + + return quotation + + +async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pdf: bool = False) -> QuotationInDB: + db = await mqtt_db.get_db() + rows = await db.execute_fetchall( + "SELECT * FROM crm_quotations WHERE id = ?", (quotation_id,) + ) + if not rows: + raise HTTPException(status_code=404, detail="Quotation not found") + + existing = dict(rows[0]) + now = datetime.utcnow().isoformat() + + # Merge update into existing values + update_fields = data.model_dump(exclude_none=True) + + # Build SET clause — handle comments JSON separately + set_parts = [] + params = [] + + scalar_fields = [ + "title", "subtitle", "language", "status", "order_type", "shipping_method", + "estimated_shipping_date", "global_discount_label", "global_discount_percent", + "shipping_cost", "shipping_cost_discount", "install_cost", + "install_cost_discount", "extras_label", "extras_cost", + "client_org", "client_name", "client_location", "client_phone", "client_email", + ] + + for field in scalar_fields: + if field in update_fields: + set_parts.append(f"{field} = ?") + params.append(update_fields[field]) + + if "comments" in update_fields: + set_parts.append("comments = ?") + params.append(json.dumps(update_fields["comments"])) + + if "quick_notes" in update_fields: + set_parts.append("quick_notes = ?") + params.append(json.dumps(update_fields["quick_notes"] or {})) + + # Recalculate totals using merged values + merged = {**existing, **{k: update_fields.get(k, existing.get(k)) for k in scalar_fields}} + + # If items are being updated, recalculate with new items; otherwise use existing items + if "items" in update_fields: + items_raw = [item.model_dump() for item in data.items] + for item in items_raw: + item["line_total"] = _calc_line_total(item) + else: + existing_items = await _fetch_items(db, quotation_id) + items_raw = existing_items + + totals = _calculate_totals( + items_raw, + float(merged.get("global_discount_percent", 0)), + float(merged.get("shipping_cost", 0)), + float(merged.get("shipping_cost_discount", 0)), + float(merged.get("install_cost", 0)), + float(merged.get("install_cost_discount", 0)), + float(merged.get("extras_cost", 0)), + ) + + for field, val in totals.items(): + set_parts.append(f"{field} = ?") + params.append(val) + + set_parts.append("updated_at = ?") + params.append(now) + params.append(quotation_id) + + if set_parts: + await db.execute( + f"UPDATE crm_quotations SET {', '.join(set_parts)} WHERE id = ?", + params, + ) + + # Replace items if provided + if "items" in update_fields: + await db.execute("DELETE FROM crm_quotation_items WHERE quotation_id = ?", (quotation_id,)) + for i, item in enumerate(items_raw): + item_id = str(uuid.uuid4()) + await db.execute( + """INSERT INTO crm_quotation_items + (id, quotation_id, product_id, description, unit_type, unit_cost, + discount_percent, quantity, vat_percent, line_total, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + item_id, quotation_id, item.get("product_id"), item.get("description"), + item.get("unit_type", "pcs"), item.get("unit_cost", 0), + item.get("discount_percent", 0), item.get("quantity", 1), + item.get("vat_percent", 24), item["line_total"], item.get("sort_order", i), + ), + ) + + await db.commit() + + quotation = await get_quotation(quotation_id) + + if generate_pdf: + quotation = await _do_generate_and_upload_pdf(quotation) + + return quotation + + +async def delete_quotation(quotation_id: str) -> None: + db = await mqtt_db.get_db() + rows = await db.execute_fetchall( + "SELECT nextcloud_pdf_path FROM crm_quotations WHERE id = ?", (quotation_id,) + ) + if not rows: + raise HTTPException(status_code=404, detail="Quotation not found") + + pdf_path = dict(rows[0]).get("nextcloud_pdf_path") + + await db.execute("DELETE FROM crm_quotation_items WHERE quotation_id = ?", (quotation_id,)) + await db.execute("DELETE FROM crm_quotations WHERE id = ?", (quotation_id,)) + await db.commit() + + # Remove PDF from Nextcloud (best-effort) + if pdf_path: + try: + await nextcloud.delete_file(pdf_path) + except Exception as e: + logger.warning("Failed to delete PDF from Nextcloud (%s): %s", pdf_path, e) + + +# ── PDF Generation ───────────────────────────────────────────────────────────── + +async def _do_generate_and_upload_pdf(quotation: QuotationInDB) -> QuotationInDB: + """Generate PDF, upload to Nextcloud, update DB record. Returns updated quotation.""" + try: + customer = get_customer(quotation.customer_id) + except Exception as e: + logger.error("Cannot generate PDF — customer not found: %s", e) + return quotation + + try: + pdf_bytes = await _generate_pdf_bytes(quotation, customer) + except Exception as e: + logger.error("PDF generation failed for quotation %s: %s", quotation.id, e) + return quotation + + # Delete old PDF if present + if quotation.nextcloud_pdf_path: + try: + await nextcloud.delete_file(quotation.nextcloud_pdf_path) + except Exception: + pass + + try: + pdf_path, pdf_url = await _upload_pdf(customer, quotation, pdf_bytes) + except Exception as e: + logger.error("PDF upload failed for quotation %s: %s", quotation.id, e) + return quotation + + # Persist paths + db = await mqtt_db.get_db() + await db.execute( + "UPDATE crm_quotations SET nextcloud_pdf_path = ?, nextcloud_pdf_url = ? WHERE id = ?", + (pdf_path, pdf_url, quotation.id), + ) + await db.commit() + + return await get_quotation(quotation.id) + + +async def _generate_pdf_bytes(quotation: QuotationInDB, customer) -> bytes: + """Render Jinja2 template and convert to PDF via WeasyPrint.""" + from jinja2 import Environment, FileSystemLoader, select_autoescape + import weasyprint + + env = Environment( + loader=FileSystemLoader(str(_TEMPLATES_DIR)), + autoescape=select_autoescape(["html"]), + ) + + def format_money(value): + try: + f = float(value) + # Greek-style: dot thousands separator, comma decimal + formatted = f"{f:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") + return f"{formatted} €" + except (TypeError, ValueError): + return "0,00 €" + + env.filters["format_money"] = format_money + + template = env.get_template("quotation.html") + + html_str = template.render( + quotation=quotation, + customer=customer, + lang=quotation.language, + ) + + pdf = weasyprint.HTML(string=html_str, base_url=str(_TEMPLATES_DIR)).write_pdf() + return pdf + + +async def _upload_pdf(customer, quotation: QuotationInDB, pdf_bytes: bytes) -> tuple[str, str]: + """Upload PDF to Nextcloud, return (relative_path, public_url).""" + from crm.service import get_customer_nc_path + from config import settings + + nc_folder = get_customer_nc_path(customer) + date_str = datetime.utcnow().strftime("%Y-%m-%d") + filename = f"Quotation-{quotation.quotation_number}-{date_str}.pdf" + rel_path = f"customers/{nc_folder}/quotations/{filename}" + + await nextcloud.ensure_folder(f"customers/{nc_folder}/quotations") + await nextcloud.upload_file(rel_path, pdf_bytes, "application/pdf") + + # Construct a direct WebDAV download URL + from crm.nextcloud import _full_url + pdf_url = _full_url(rel_path) + + return rel_path, pdf_url + + +async def regenerate_pdf(quotation_id: str) -> QuotationInDB: + quotation = await get_quotation(quotation_id) + return await _do_generate_and_upload_pdf(quotation) + + +async def get_quotation_pdf_bytes(quotation_id: str) -> bytes: + """Download the PDF for a quotation from Nextcloud and return raw bytes.""" + from fastapi import HTTPException + quotation = await get_quotation(quotation_id) + if not quotation.nextcloud_pdf_path: + raise HTTPException(status_code=404, detail="No PDF generated for this quotation") + pdf_bytes, _ = await nextcloud.download_file(quotation.nextcloud_pdf_path) + return pdf_bytes diff --git a/backend/crm/router.py b/backend/crm/router.py new file mode 100644 index 0000000..f077507 --- /dev/null +++ b/backend/crm/router.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException +from fastapi.responses import FileResponse +from typing import Optional +import os +import shutil + +from auth.models import TokenPayload +from auth.dependencies import require_permission +from crm.models import ProductCreate, ProductUpdate, ProductInDB, ProductListResponse +from crm import service + +router = APIRouter(prefix="/api/crm/products", tags=["crm-products"]) + +PHOTO_DIR = os.path.join(os.path.dirname(__file__), "..", "storage", "product_images") +os.makedirs(PHOTO_DIR, exist_ok=True) + + +@router.get("", response_model=ProductListResponse) +def list_products( + search: Optional[str] = Query(None), + category: Optional[str] = Query(None), + active_only: bool = Query(False), + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + products = service.list_products(search=search, category=category, active_only=active_only) + return ProductListResponse(products=products, total=len(products)) + + +@router.get("/{product_id}", response_model=ProductInDB) +def get_product( + product_id: str, + _user: TokenPayload = Depends(require_permission("crm", "view")), +): + return service.get_product(product_id) + + +@router.post("", response_model=ProductInDB, status_code=201) +def create_product( + body: ProductCreate, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + return service.create_product(body) + + +@router.put("/{product_id}", response_model=ProductInDB) +def update_product( + product_id: str, + body: ProductUpdate, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + return service.update_product(product_id, body) + + +@router.delete("/{product_id}", status_code=204) +def delete_product( + product_id: str, + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + service.delete_product(product_id) + + +@router.post("/{product_id}/photo", response_model=ProductInDB) +async def upload_product_photo( + product_id: str, + file: UploadFile = File(...), + _user: TokenPayload = Depends(require_permission("crm", "edit")), +): + """Upload a product photo. Accepts JPG or PNG, stored on disk.""" + if file.content_type not in ("image/jpeg", "image/png", "image/webp"): + raise HTTPException(status_code=400, detail="Only JPG, PNG, or WebP images are accepted.") + ext = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"}.get(file.content_type, "jpg") + photo_path = os.path.join(PHOTO_DIR, f"{product_id}.{ext}") + # Remove any old photo files for this product + for old_ext in ("jpg", "png", "webp"): + old_path = os.path.join(PHOTO_DIR, f"{product_id}.{old_ext}") + if os.path.exists(old_path) and old_path != photo_path: + os.remove(old_path) + with open(photo_path, "wb") as f: + shutil.copyfileobj(file.file, f) + photo_url = f"/crm/products/{product_id}/photo" + return service.update_product(product_id, ProductUpdate(photo_url=photo_url)) + + +@router.get("/{product_id}/photo") +def get_product_photo( + product_id: str, +): + """Serve a product photo from disk.""" + for ext in ("jpg", "png", "webp"): + photo_path = os.path.join(PHOTO_DIR, f"{product_id}.{ext}") + if os.path.exists(photo_path): + return FileResponse(photo_path) + raise HTTPException(status_code=404, detail="No photo found for this product.") diff --git a/backend/crm/service.py b/backend/crm/service.py new file mode 100644 index 0000000..3db0522 --- /dev/null +++ b/backend/crm/service.py @@ -0,0 +1,619 @@ +import json +import uuid +from datetime import datetime + +from fastapi import HTTPException +from shared.firebase import get_db +from shared.exceptions import NotFoundError +import re as _re +from mqtt import database as mqtt_db +from crm.models import ( + ProductCreate, ProductUpdate, ProductInDB, + CustomerCreate, CustomerUpdate, CustomerInDB, + OrderCreate, OrderUpdate, OrderInDB, + CommCreate, CommUpdate, CommInDB, + MediaCreate, MediaInDB, +) + +COLLECTION = "crm_products" + + +def _doc_to_product(doc) -> ProductInDB: + data = doc.to_dict() + return ProductInDB(id=doc.id, **data) + + +def list_products( + search: str | None = None, + category: str | None = None, + active_only: bool = False, +) -> list[ProductInDB]: + db = get_db() + query = db.collection(COLLECTION) + + if active_only: + query = query.where("active", "==", True) + + if category: + query = query.where("category", "==", category) + + results = [] + for doc in query.stream(): + product = _doc_to_product(doc) + + if search: + s = search.lower() + if not ( + s in (product.name or "").lower() + or s in (product.sku or "").lower() + or s in (product.description or "").lower() + ): + continue + + results.append(product) + + return results + + +def get_product(product_id: str) -> ProductInDB: + db = get_db() + doc = db.collection(COLLECTION).document(product_id).get() + if not doc.exists: + raise NotFoundError("Product") + return _doc_to_product(doc) + + +def create_product(data: ProductCreate) -> ProductInDB: + db = get_db() + now = datetime.utcnow().isoformat() + product_id = str(uuid.uuid4()) + + doc_data = data.model_dump() + doc_data["created_at"] = now + doc_data["updated_at"] = now + + # Serialize nested enums/models + if doc_data.get("category"): + doc_data["category"] = doc_data["category"].value if hasattr(doc_data["category"], "value") else doc_data["category"] + if doc_data.get("costs") and hasattr(doc_data["costs"], "model_dump"): + doc_data["costs"] = doc_data["costs"].model_dump() + if doc_data.get("stock") and hasattr(doc_data["stock"], "model_dump"): + doc_data["stock"] = doc_data["stock"].model_dump() + + db.collection(COLLECTION).document(product_id).set(doc_data) + return ProductInDB(id=product_id, **doc_data) + + +def update_product(product_id: str, data: ProductUpdate) -> ProductInDB: + db = get_db() + doc_ref = db.collection(COLLECTION).document(product_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Product") + + update_data = data.model_dump(exclude_none=True) + update_data["updated_at"] = datetime.utcnow().isoformat() + + if "category" in update_data and hasattr(update_data["category"], "value"): + update_data["category"] = update_data["category"].value + if "costs" in update_data and hasattr(update_data["costs"], "model_dump"): + update_data["costs"] = update_data["costs"].model_dump() + if "stock" in update_data and hasattr(update_data["stock"], "model_dump"): + update_data["stock"] = update_data["stock"].model_dump() + + doc_ref.update(update_data) + updated_doc = doc_ref.get() + return _doc_to_product(updated_doc) + + +def delete_product(product_id: str) -> None: + db = get_db() + doc_ref = db.collection(COLLECTION).document(product_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Product") + doc_ref.delete() + + +# ── Customers ──────────────────────────────────────────────────────────────── + +CUSTOMERS_COLLECTION = "crm_customers" + + +def _doc_to_customer(doc) -> CustomerInDB: + data = doc.to_dict() + return CustomerInDB(id=doc.id, **data) + + +def list_customers( + search: str | None = None, + tag: str | None = None, +) -> list[CustomerInDB]: + db = get_db() + query = db.collection(CUSTOMERS_COLLECTION) + + if tag: + query = query.where("tags", "array_contains", tag) + + results = [] + for doc in query.stream(): + customer = _doc_to_customer(doc) + + if search: + s = search.lower() + name_match = s in (customer.name or "").lower() + surname_match = s in (customer.surname or "").lower() + org_match = s in (customer.organization or "").lower() + contact_match = any( + s in (c.value or "").lower() + for c in (customer.contacts or []) + ) + loc = customer.location or {} + loc_match = ( + s in (loc.get("city", "") or "").lower() or + s in (loc.get("country", "") or "").lower() or + s in (loc.get("region", "") or "").lower() + ) + tag_match = any(s in (t or "").lower() for t in (customer.tags or [])) + if not (name_match or surname_match or org_match or contact_match or loc_match or tag_match): + continue + + results.append(customer) + + return results + + +def get_customer(customer_id: str) -> CustomerInDB: + db = get_db() + doc = db.collection(CUSTOMERS_COLLECTION).document(customer_id).get() + if not doc.exists: + raise NotFoundError("Customer") + return _doc_to_customer(doc) + + +def get_customer_nc_path(customer: CustomerInDB) -> str: + """Return the Nextcloud folder slug for a customer. Falls back to UUID for legacy records.""" + return customer.folder_id if customer.folder_id else customer.id + + +def create_customer(data: CustomerCreate) -> CustomerInDB: + db = get_db() + + # Validate folder_id + if not data.folder_id or not data.folder_id.strip(): + raise HTTPException(status_code=422, detail="Internal Folder ID is required.") + folder_id = data.folder_id.strip().lower() + if not _re.match(r'^[a-z0-9][a-z0-9\-]*[a-z0-9]$', folder_id): + raise HTTPException( + status_code=422, + detail="Internal Folder ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.", + ) + # Check uniqueness + existing = list(db.collection(CUSTOMERS_COLLECTION).where("folder_id", "==", folder_id).limit(1).stream()) + if existing: + raise HTTPException(status_code=409, detail=f"A customer with folder ID '{folder_id}' already exists.") + + now = datetime.utcnow().isoformat() + customer_id = str(uuid.uuid4()) + + doc_data = data.model_dump() + doc_data["folder_id"] = folder_id + doc_data["created_at"] = now + doc_data["updated_at"] = now + + db.collection(CUSTOMERS_COLLECTION).document(customer_id).set(doc_data) + return CustomerInDB(id=customer_id, **doc_data) + + +def update_customer(customer_id: str, data: CustomerUpdate) -> CustomerInDB: + db = get_db() + doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Customer") + + update_data = data.model_dump(exclude_none=True) + update_data["updated_at"] = datetime.utcnow().isoformat() + + doc_ref.update(update_data) + updated_doc = doc_ref.get() + return _doc_to_customer(updated_doc) + + +def delete_customer(customer_id: str) -> None: + db = get_db() + doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Customer") + doc_ref.delete() + + +# ── Orders ─────────────────────────────────────────────────────────────────── + +ORDERS_COLLECTION = "crm_orders" + + +def _doc_to_order(doc) -> OrderInDB: + data = doc.to_dict() + return OrderInDB(id=doc.id, **data) + + +def _generate_order_number(db) -> str: + year = datetime.utcnow().year + prefix = f"ORD-{year}-" + max_n = 0 + for doc in db.collection(ORDERS_COLLECTION).stream(): + data = doc.to_dict() + num = data.get("order_number", "") + if num and num.startswith(prefix): + try: + n = int(num[len(prefix):]) + if n > max_n: + max_n = n + except ValueError: + pass + return f"{prefix}{max_n + 1:03d}" + + +def list_orders( + customer_id: str | None = None, + status: str | None = None, + payment_status: str | None = None, +) -> list[OrderInDB]: + db = get_db() + query = db.collection(ORDERS_COLLECTION) + + if customer_id: + query = query.where("customer_id", "==", customer_id) + if status: + query = query.where("status", "==", status) + if payment_status: + query = query.where("payment_status", "==", payment_status) + + return [_doc_to_order(doc) for doc in query.stream()] + + +def get_order(order_id: str) -> OrderInDB: + db = get_db() + doc = db.collection(ORDERS_COLLECTION).document(order_id).get() + if not doc.exists: + raise NotFoundError("Order") + return _doc_to_order(doc) + + +def create_order(data: OrderCreate) -> OrderInDB: + db = get_db() + now = datetime.utcnow().isoformat() + order_id = str(uuid.uuid4()) + + doc_data = data.model_dump() + if not doc_data.get("order_number"): + doc_data["order_number"] = _generate_order_number(db) + doc_data["created_at"] = now + doc_data["updated_at"] = now + + db.collection(ORDERS_COLLECTION).document(order_id).set(doc_data) + return OrderInDB(id=order_id, **doc_data) + + +def update_order(order_id: str, data: OrderUpdate) -> OrderInDB: + db = get_db() + doc_ref = db.collection(ORDERS_COLLECTION).document(order_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Order") + + update_data = data.model_dump(exclude_none=True) + update_data["updated_at"] = datetime.utcnow().isoformat() + + doc_ref.update(update_data) + updated_doc = doc_ref.get() + return _doc_to_order(updated_doc) + + +def delete_order(order_id: str) -> None: + db = get_db() + doc_ref = db.collection(ORDERS_COLLECTION).document(order_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Order") + doc_ref.delete() + + +# ── Comms Log (SQLite, async) ───────────────────────────────────────────────── + +def _row_to_comm(row: dict) -> CommInDB: + row = dict(row) + raw_attachments = json.loads(row.get("attachments") or "[]") + # Normalise attachment dicts — tolerate both synced (content_type/size) and + # sent (nextcloud_path) shapes so Pydantic never sees missing required fields. + row["attachments"] = [ + {k: v for k, v in a.items() if k in ("filename", "nextcloud_path", "content_type", "size")} + for a in raw_attachments if isinstance(a, dict) and a.get("filename") + ] + if row.get("to_addrs") and isinstance(row["to_addrs"], str): + try: + row["to_addrs"] = json.loads(row["to_addrs"]) + except Exception: + row["to_addrs"] = [] + # SQLite stores booleans as integers + row["is_important"] = bool(row.get("is_important", 0)) + row["is_read"] = bool(row.get("is_read", 0)) + return CommInDB(**{k: v for k, v in row.items() if k in CommInDB.model_fields}) + + +async def list_comms( + customer_id: str, + type: str | None = None, + direction: str | None = None, + limit: int = 100, +) -> list[CommInDB]: + db = await mqtt_db.get_db() + where = ["customer_id = ?"] + params: list = [customer_id] + if type: + where.append("type = ?") + params.append(type) + if direction: + where.append("direction = ?") + params.append(direction) + clause = " AND ".join(where) + rows = await db.execute_fetchall( + f"SELECT * FROM crm_comms_log WHERE {clause} ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?", + params + [limit], + ) + entries = [_row_to_comm(dict(r)) for r in rows] + + # Fallback: include unlinked email rows (customer_id NULL) if addresses match this customer. + # This covers historical rows created before automatic outbound customer linking. + fs = get_db() + doc = fs.collection("crm_customers").document(customer_id).get() + if doc.exists: + data = doc.to_dict() or {} + customer_emails = { + (c.get("value") or "").strip().lower() + for c in (data.get("contacts") or []) + if c.get("type") == "email" and c.get("value") + } + else: + customer_emails = set() + + if customer_emails: + extra_where = [ + "type = 'email'", + "(customer_id IS NULL OR customer_id = '')", + ] + extra_params: list = [] + if direction: + extra_where.append("direction = ?") + extra_params.append(direction) + extra_clause = " AND ".join(extra_where) + extra_rows = await db.execute_fetchall( + f"SELECT * FROM crm_comms_log WHERE {extra_clause} " + "ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?", + extra_params + [max(limit, 300)], + ) + for r in extra_rows: + e = _row_to_comm(dict(r)) + from_addr = (e.from_addr or "").strip().lower() + to_addrs = [(a or "").strip().lower() for a in (e.to_addrs or [])] + matched = (from_addr in customer_emails) or any(a in customer_emails for a in to_addrs) + if matched: + entries.append(e) + + # De-duplicate and sort consistently + uniq = {e.id: e for e in entries} + sorted_entries = sorted( + uniq.values(), + key=lambda e: ((e.occurred_at or e.created_at or ""), (e.created_at or ""), (e.id or "")), + reverse=True, + ) + return sorted_entries[:limit] + + +async def list_all_emails( + direction: str | None = None, + customers_only: bool = False, + mail_accounts: list[str] | None = None, + limit: int = 500, +) -> list[CommInDB]: + db = await mqtt_db.get_db() + where = ["type = 'email'"] + params: list = [] + if direction: + where.append("direction = ?") + params.append(direction) + if customers_only: + where.append("customer_id IS NOT NULL") + if mail_accounts: + placeholders = ",".join("?" for _ in mail_accounts) + where.append(f"mail_account IN ({placeholders})") + params.extend(mail_accounts) + clause = f"WHERE {' AND '.join(where)}" + rows = await db.execute_fetchall( + f"SELECT * FROM crm_comms_log {clause} ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?", + params + [limit], + ) + return [_row_to_comm(dict(r)) for r in rows] + + +async def list_all_comms( + type: str | None = None, + direction: str | None = None, + limit: int = 200, +) -> list[CommInDB]: + db = await mqtt_db.get_db() + where = [] + params: list = [] + if type: + where.append("type = ?") + params.append(type) + if direction: + where.append("direction = ?") + params.append(direction) + clause = f"WHERE {' AND '.join(where)}" if where else "" + rows = await db.execute_fetchall( + f"SELECT * FROM crm_comms_log {clause} ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT ?", + params + [limit], + ) + return [_row_to_comm(dict(r)) for r in rows] + + +async def get_comm(comm_id: str) -> CommInDB: + db = await mqtt_db.get_db() + rows = await db.execute_fetchall( + "SELECT * FROM crm_comms_log WHERE id = ?", (comm_id,) + ) + if not rows: + raise HTTPException(status_code=404, detail="Comm entry not found") + return _row_to_comm(dict(rows[0])) + + +async def create_comm(data: CommCreate) -> CommInDB: + db = await mqtt_db.get_db() + now = datetime.utcnow().isoformat() + comm_id = str(uuid.uuid4()) + occurred_at = data.occurred_at or now + attachments_json = json.dumps([a.model_dump() for a in data.attachments]) + + await db.execute( + """INSERT INTO crm_comms_log + (id, customer_id, type, mail_account, direction, subject, body, attachments, + ext_message_id, logged_by, occurred_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (comm_id, data.customer_id, data.type.value, data.mail_account, data.direction.value, + data.subject, data.body, attachments_json, + data.ext_message_id, data.logged_by, occurred_at, now), + ) + await db.commit() + return await get_comm(comm_id) + + +async def update_comm(comm_id: str, data: CommUpdate) -> CommInDB: + db = await mqtt_db.get_db() + rows = await db.execute_fetchall( + "SELECT id FROM crm_comms_log WHERE id = ?", (comm_id,) + ) + if not rows: + raise HTTPException(status_code=404, detail="Comm entry not found") + + updates = data.model_dump(exclude_none=True) + if not updates: + return await get_comm(comm_id) + + set_clause = ", ".join(f"{k} = ?" for k in updates) + await db.execute( + f"UPDATE crm_comms_log SET {set_clause} WHERE id = ?", + list(updates.values()) + [comm_id], + ) + await db.commit() + return await get_comm(comm_id) + + +async def delete_comm(comm_id: str) -> None: + db = await mqtt_db.get_db() + rows = await db.execute_fetchall( + "SELECT id FROM crm_comms_log WHERE id = ?", (comm_id,) + ) + if not rows: + raise HTTPException(status_code=404, detail="Comm entry not found") + await db.execute("DELETE FROM crm_comms_log WHERE id = ?", (comm_id,)) + await db.commit() + + +async def delete_comms_bulk(ids: list[str]) -> int: + """Delete multiple comm entries. Returns count deleted.""" + if not ids: + return 0 + db = await mqtt_db.get_db() + placeholders = ",".join("?" for _ in ids) + cursor = await db.execute( + f"DELETE FROM crm_comms_log WHERE id IN ({placeholders})", ids + ) + await db.commit() + return cursor.rowcount + + +async def set_comm_important(comm_id: str, important: bool) -> CommInDB: + db = await mqtt_db.get_db() + await db.execute( + "UPDATE crm_comms_log SET is_important = ? WHERE id = ?", + (1 if important else 0, comm_id), + ) + await db.commit() + return await get_comm(comm_id) + + +async def set_comm_read(comm_id: str, read: bool) -> CommInDB: + db = await mqtt_db.get_db() + await db.execute( + "UPDATE crm_comms_log SET is_read = ? WHERE id = ?", + (1 if read else 0, comm_id), + ) + await db.commit() + return await get_comm(comm_id) + + +# ── Media (SQLite, async) ───────────────────────────────────────────────────── + +def _row_to_media(row: dict) -> MediaInDB: + row = dict(row) + row["tags"] = json.loads(row.get("tags") or "[]") + return MediaInDB(**row) + + +async def list_media( + customer_id: str | None = None, + order_id: str | None = None, +) -> list[MediaInDB]: + db = await mqtt_db.get_db() + where = [] + params: list = [] + if customer_id: + where.append("customer_id = ?") + params.append(customer_id) + if order_id: + where.append("order_id = ?") + params.append(order_id) + clause = f"WHERE {' AND '.join(where)}" if where else "" + rows = await db.execute_fetchall( + f"SELECT * FROM crm_media {clause} ORDER BY created_at DESC", + params, + ) + return [_row_to_media(dict(r)) for r in rows] + + +async def create_media(data: MediaCreate) -> MediaInDB: + db = await mqtt_db.get_db() + now = datetime.utcnow().isoformat() + media_id = str(uuid.uuid4()) + tags_json = json.dumps(data.tags) + direction = data.direction.value if data.direction else None + + await db.execute( + """INSERT INTO crm_media + (id, customer_id, order_id, filename, nextcloud_path, mime_type, + direction, tags, uploaded_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (media_id, data.customer_id, data.order_id, data.filename, + data.nextcloud_path, data.mime_type, direction, + tags_json, data.uploaded_by, now), + ) + await db.commit() + + rows = await db.execute_fetchall( + "SELECT * FROM crm_media WHERE id = ?", (media_id,) + ) + return _row_to_media(dict(rows[0])) + + +async def delete_media(media_id: str) -> None: + db = await mqtt_db.get_db() + rows = await db.execute_fetchall( + "SELECT id FROM crm_media WHERE id = ?", (media_id,) + ) + if not rows: + raise HTTPException(status_code=404, detail="Media entry not found") + await db.execute("DELETE FROM crm_media WHERE id = ?", (media_id,)) + await db.commit() diff --git a/backend/devices/router.py b/backend/devices/router.py index efce87d..deddb10 100644 --- a/backend/devices/router.py +++ b/backend/devices/router.py @@ -7,6 +7,8 @@ from devices.models import ( DeviceUsersResponse, DeviceUserInfo, ) from devices import service +from mqtt import database as mqtt_db +from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse router = APIRouter(prefix="/api/devices", tags=["devices"]) @@ -67,3 +69,13 @@ async def delete_device( _user: TokenPayload = Depends(require_permission("devices", "delete")), ): service.delete_device(device_id) + + +@router.get("/{device_id}/alerts", response_model=DeviceAlertsResponse) +async def get_device_alerts( + device_id: str, + _user: TokenPayload = Depends(require_permission("devices", "view")), +): + """Return the current active alert set for a device. Empty list means fully healthy.""" + rows = await mqtt_db.get_alerts(device_id) + return DeviceAlertsResponse(alerts=[DeviceAlertEntry(**r) for r in rows]) diff --git a/backend/firmware/models.py b/backend/firmware/models.py index b99eb99..65b5add 100644 --- a/backend/firmware/models.py +++ b/backend/firmware/models.py @@ -1,15 +1,24 @@ from pydantic import BaseModel from typing import Optional, List +from enum import Enum + + +class UpdateType(str, Enum): + optional = "optional" # user-initiated only + mandatory = "mandatory" # auto-installs on next reboot + emergency = "emergency" # auto-installs on reboot + daily check + MQTT push class FirmwareVersion(BaseModel): id: str - hw_type: str # "vs", "vp", "vx" - channel: str # "stable", "beta", "alpha", "testing" - version: str # semver e.g. "1.4.2" + hw_type: str # e.g. "vesper", "vesper_plus", "vesper_pro" + channel: str # "stable", "beta", "alpha", "testing" + version: str # semver e.g. "1.5" filename: str size_bytes: int sha256: str + update_type: UpdateType = UpdateType.mandatory + min_fw_version: Optional[str] = None # minimum fw version required to install this uploaded_at: str notes: Optional[str] = None is_latest: bool = False @@ -20,12 +29,19 @@ class FirmwareListResponse(BaseModel): total: int -class FirmwareLatestResponse(BaseModel): +class FirmwareMetadataResponse(BaseModel): + """Returned by both /latest and /{version}/info endpoints.""" hw_type: str channel: str version: str size_bytes: int sha256: str + update_type: UpdateType + min_fw_version: Optional[str] = None download_url: str uploaded_at: str notes: Optional[str] = None + + +# Keep backwards-compatible alias +FirmwareLatestResponse = FirmwareMetadataResponse diff --git a/backend/firmware/router.py b/backend/firmware/router.py index dcebbeb..1806dc2 100644 --- a/backend/firmware/router.py +++ b/backend/firmware/router.py @@ -4,7 +4,7 @@ from typing import Optional from auth.models import TokenPayload from auth.dependencies import require_permission -from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareLatestResponse +from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType from firmware import service router = APIRouter(prefix="/api/firmware", tags=["firmware"]) @@ -15,6 +15,8 @@ async def upload_firmware( hw_type: str = Form(...), channel: str = Form(...), version: str = Form(...), + update_type: UpdateType = Form(UpdateType.mandatory), + min_fw_version: Optional[str] = Form(None), notes: Optional[str] = Form(None), file: UploadFile = File(...), _user: TokenPayload = Depends(require_permission("manufacturing", "add")), @@ -25,6 +27,8 @@ async def upload_firmware( channel=channel, version=version, file_bytes=file_bytes, + update_type=update_type, + min_fw_version=min_fw_version, notes=notes, ) @@ -39,7 +43,7 @@ def list_firmware( return FirmwareListResponse(firmware=items, total=len(items)) -@router.get("/{hw_type}/{channel}/latest", response_model=FirmwareLatestResponse) +@router.get("/{hw_type}/{channel}/latest", response_model=FirmwareMetadataResponse) def get_latest_firmware(hw_type: str, channel: str): """Returns metadata for the latest firmware for a given hw_type + channel. No auth required — devices call this endpoint to check for updates. @@ -47,6 +51,14 @@ def get_latest_firmware(hw_type: str, channel: str): return service.get_latest(hw_type, channel) +@router.get("/{hw_type}/{channel}/{version}/info", response_model=FirmwareMetadataResponse) +def get_firmware_info(hw_type: str, channel: str, version: str): + """Returns metadata for a specific firmware version. + No auth required — devices call this to resolve upgrade chains. + """ + return service.get_version_info(hw_type, channel, version) + + @router.get("/{hw_type}/{channel}/{version}/firmware.bin") def download_firmware(hw_type: str, channel: str, version: str): """Download the firmware binary. No auth required — devices call this directly.""" diff --git a/backend/firmware/service.py b/backend/firmware/service.py index c65a77f..5772917 100644 --- a/backend/firmware/service.py +++ b/backend/firmware/service.py @@ -8,11 +8,11 @@ from fastapi import HTTPException from config import settings from shared.firebase import get_db from shared.exceptions import NotFoundError -from firmware.models import FirmwareVersion, FirmwareLatestResponse +from firmware.models import FirmwareVersion, FirmwareMetadataResponse, UpdateType COLLECTION = "firmware_versions" -VALID_HW_TYPES = {"vs", "vp", "vx"} +VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini"} VALID_CHANNELS = {"stable", "beta", "alpha", "testing"} @@ -36,23 +36,43 @@ def _doc_to_firmware_version(doc) -> FirmwareVersion: filename=data.get("filename", "firmware.bin"), size_bytes=data.get("size_bytes", 0), sha256=data.get("sha256", ""), + update_type=data.get("update_type", UpdateType.mandatory), + min_fw_version=data.get("min_fw_version"), uploaded_at=uploaded_str, notes=data.get("notes"), is_latest=data.get("is_latest", False), ) +def _fw_to_metadata_response(fw: FirmwareVersion) -> FirmwareMetadataResponse: + download_url = f"/api/firmware/{fw.hw_type}/{fw.channel}/{fw.version}/firmware.bin" + return FirmwareMetadataResponse( + hw_type=fw.hw_type, + channel=fw.channel, + version=fw.version, + size_bytes=fw.size_bytes, + sha256=fw.sha256, + update_type=fw.update_type, + min_fw_version=fw.min_fw_version, + download_url=download_url, + uploaded_at=fw.uploaded_at, + notes=fw.notes, + ) + + def upload_firmware( hw_type: str, channel: str, version: str, file_bytes: bytes, + update_type: UpdateType = UpdateType.mandatory, + min_fw_version: str | None = None, notes: str | None = None, ) -> FirmwareVersion: if hw_type not in VALID_HW_TYPES: - raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(VALID_HW_TYPES)}") + raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES))}") if channel not in VALID_CHANNELS: - raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(VALID_CHANNELS)}") + raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(sorted(VALID_CHANNELS))}") dest = _storage_path(hw_type, channel, version) dest.parent.mkdir(parents=True, exist_ok=True) @@ -83,6 +103,8 @@ def upload_firmware( "filename": "firmware.bin", "size_bytes": len(file_bytes), "sha256": sha256, + "update_type": update_type.value, + "min_fw_version": min_fw_version, "uploaded_at": now, "notes": notes, "is_latest": True, @@ -108,7 +130,7 @@ def list_firmware( return items -def get_latest(hw_type: str, channel: str) -> FirmwareLatestResponse: +def get_latest(hw_type: str, channel: str) -> FirmwareMetadataResponse: if hw_type not in VALID_HW_TYPES: raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'") if channel not in VALID_CHANNELS: @@ -126,18 +148,29 @@ def get_latest(hw_type: str, channel: str) -> FirmwareLatestResponse: if not docs: raise NotFoundError("Firmware") - fw = _doc_to_firmware_version(docs[0]) - download_url = f"/api/firmware/{hw_type}/{channel}/{fw.version}/firmware.bin" - return FirmwareLatestResponse( - hw_type=fw.hw_type, - channel=fw.channel, - version=fw.version, - size_bytes=fw.size_bytes, - sha256=fw.sha256, - download_url=download_url, - uploaded_at=fw.uploaded_at, - notes=fw.notes, + return _fw_to_metadata_response(_doc_to_firmware_version(docs[0])) + + +def get_version_info(hw_type: str, channel: str, version: str) -> FirmwareMetadataResponse: + """Fetch metadata for a specific version. Used by devices resolving upgrade chains.""" + if hw_type not in VALID_HW_TYPES: + raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'") + if channel not in VALID_CHANNELS: + raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'") + + db = get_db() + docs = list( + db.collection(COLLECTION) + .where("hw_type", "==", hw_type) + .where("channel", "==", channel) + .where("version", "==", version) + .limit(1) + .stream() ) + if not docs: + raise NotFoundError("Firmware version") + + return _fw_to_metadata_response(_doc_to_firmware_version(docs[0])) def get_firmware_path(hw_type: str, channel: str, version: str) -> Path: diff --git a/backend/main.py b/backend/main.py index 4fe19a5..65402ef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,6 +17,15 @@ from builder.router import router as builder_router from manufacturing.router import router as manufacturing_router from firmware.router import router as firmware_router from admin.router import router as admin_router +from crm.router import router as crm_products_router +from crm.customers_router import router as crm_customers_router +from crm.orders_router import router as crm_orders_router +from crm.comms_router import router as crm_comms_router +from crm.media_router import router as crm_media_router +from crm.nextcloud_router import router as crm_nextcloud_router +from crm.quotations_router import router as crm_quotations_router +from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive +from crm.mail_accounts import get_mail_accounts from mqtt.client import mqtt_manager from mqtt import database as mqtt_db from melodies import service as melody_service @@ -50,6 +59,30 @@ app.include_router(builder_router) app.include_router(manufacturing_router) app.include_router(firmware_router) app.include_router(admin_router) +app.include_router(crm_products_router) +app.include_router(crm_customers_router) +app.include_router(crm_orders_router) +app.include_router(crm_comms_router) +app.include_router(crm_media_router) +app.include_router(crm_nextcloud_router) +app.include_router(crm_quotations_router) + + +async def nextcloud_keepalive_loop(): + await nextcloud_keepalive() # eager warmup on startup + while True: + await asyncio.sleep(45) + await nextcloud_keepalive() + + +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}") @app.on_event("startup") @@ -59,12 +92,20 @@ async def startup(): await melody_service.migrate_from_firestore() mqtt_manager.start(asyncio.get_event_loop()) asyncio.create_task(mqtt_db.purge_loop()) + asyncio.create_task(nextcloud_keepalive_loop()) + sync_accounts = [a for a in get_mail_accounts() if a.get("sync_inbound") and a.get("imap_host")] + if sync_accounts: + print(f"[EMAIL SYNC] IMAP configured for {len(sync_accounts)} account(s) - starting sync loop") + asyncio.create_task(email_sync_loop()) + else: + print("[EMAIL SYNC] IMAP not configured - sync loop disabled") @app.on_event("shutdown") async def shutdown(): mqtt_manager.stop() await mqtt_db.close_db() + await close_nextcloud_client() @app.get("/api/health") @@ -74,3 +115,4 @@ async def health_check(): "firebase": firebase_initialized, "mqtt": mqtt_manager.connected, } + diff --git a/backend/manufacturing/models.py b/backend/manufacturing/models.py index ed48648..78cc77b 100644 --- a/backend/manufacturing/models.py +++ b/backend/manufacturing/models.py @@ -4,23 +4,23 @@ from enum import Enum class BoardType(str, Enum): - vs = "vs" # Vesper - vp = "vp" # Vesper Plus - vx = "vx" # Vesper Pro - cb = "cb" # Chronos - cp = "cp" # Chronos Pro - am = "am" # Agnus Mini - ab = "ab" # Agnus + vesper = "vesper" + vesper_plus = "vesper_plus" + vesper_pro = "vesper_pro" + chronos = "chronos" + chronos_pro = "chronos_pro" + agnus_mini = "agnus_mini" + agnus = "agnus" BOARD_TYPE_LABELS = { - "vs": "Vesper", - "vp": "Vesper Plus", - "vx": "Vesper Pro", - "cb": "Chronos", - "cp": "Chronos Pro", - "am": "Agnus Mini", - "ab": "Agnus", + "vesper": "Vesper", + "vesper_plus": "Vesper+", + "vesper_pro": "Vesper Pro", + "chronos": "Chronos", + "chronos_pro": "Chronos Pro", + "agnus_mini": "Agnus Mini", + "agnus": "Agnus", } diff --git a/backend/mqtt/client.py b/backend/mqtt/client.py index eb4743f..103f806 100644 --- a/backend/mqtt/client.py +++ b/backend/mqtt/client.py @@ -26,7 +26,7 @@ class MqttManager: self._client = paho_mqtt.Client( callback_api_version=paho_mqtt.CallbackAPIVersion.VERSION2, - client_id="bellsystems-admin-panel", + client_id=settings.mqtt_client_id, clean_session=True, ) @@ -64,6 +64,8 @@ class MqttManager: client.subscribe([ ("vesper/+/data", 1), ("vesper/+/status/heartbeat", 1), + ("vesper/+/status/alerts", 1), + ("vesper/+/status/info", 0), ("vesper/+/logs", 1), ]) else: diff --git a/backend/mqtt/database.py b/backend/mqtt/database.py index bd09439..d76aa82 100644 --- a/backend/mqtt/database.py +++ b/backend/mqtt/database.py @@ -76,6 +76,102 @@ SCHEMA_STATEMENTS = [ )""", "CREATE INDEX IF NOT EXISTS idx_mfg_audit_time ON mfg_audit_log(timestamp)", "CREATE INDEX IF NOT EXISTS idx_mfg_audit_action ON mfg_audit_log(action)", + # Active device alerts (current state, not history) + """CREATE TABLE IF NOT EXISTS device_alerts ( + device_serial TEXT NOT NULL, + subsystem TEXT NOT NULL, + state TEXT NOT NULL, + message TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (device_serial, subsystem) + )""", + "CREATE INDEX IF NOT EXISTS idx_device_alerts_serial ON device_alerts(device_serial)", + # CRM communications log + """CREATE TABLE IF NOT EXISTS crm_comms_log ( + id TEXT PRIMARY KEY, + customer_id TEXT, + type TEXT NOT NULL, + mail_account TEXT, + direction TEXT NOT NULL, + subject TEXT, + body TEXT, + body_html TEXT, + attachments TEXT NOT NULL DEFAULT '[]', + ext_message_id TEXT, + from_addr TEXT, + to_addrs TEXT, + logged_by TEXT, + occurred_at TEXT NOT NULL, + created_at TEXT NOT NULL + )""", + "CREATE INDEX IF NOT EXISTS idx_crm_comms_customer ON crm_comms_log(customer_id, occurred_at)", + # CRM media references + """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 NOT NULL DEFAULT '[]', + uploaded_by TEXT, + created_at TEXT NOT NULL + )""", + "CREATE INDEX IF NOT EXISTS idx_crm_media_customer ON crm_media(customer_id)", + "CREATE INDEX IF NOT EXISTS idx_crm_media_order ON crm_media(order_id)", + # CRM sync state (last email sync timestamp, etc.) + """CREATE TABLE IF NOT EXISTS crm_sync_state ( + key TEXT PRIMARY KEY, + value TEXT + )""", + # CRM Quotations + """CREATE TABLE IF NOT EXISTS crm_quotations ( + id TEXT PRIMARY KEY, + quotation_number TEXT UNIQUE NOT NULL, + title TEXT, + subtitle TEXT, + customer_id TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'en', + status TEXT NOT NULL DEFAULT 'draft', + order_type TEXT, + shipping_method TEXT, + estimated_shipping_date TEXT, + global_discount_label TEXT, + global_discount_percent REAL NOT NULL DEFAULT 0, + vat_percent REAL NOT NULL DEFAULT 24, + shipping_cost REAL NOT NULL DEFAULT 0, + shipping_cost_discount REAL NOT NULL DEFAULT 0, + install_cost REAL NOT NULL DEFAULT 0, + install_cost_discount REAL NOT NULL DEFAULT 0, + extras_label TEXT, + extras_cost REAL NOT NULL DEFAULT 0, + comments TEXT NOT NULL DEFAULT '[]', + subtotal_before_discount REAL NOT NULL DEFAULT 0, + global_discount_amount REAL NOT NULL DEFAULT 0, + new_subtotal REAL NOT NULL DEFAULT 0, + vat_amount REAL NOT NULL DEFAULT 0, + final_total REAL NOT NULL DEFAULT 0, + nextcloud_pdf_path TEXT, + nextcloud_pdf_url TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )""", + """CREATE TABLE IF NOT EXISTS crm_quotation_items ( + id TEXT PRIMARY KEY, + quotation_id TEXT NOT NULL, + product_id TEXT, + description TEXT, + unit_type TEXT NOT NULL DEFAULT 'pcs', + unit_cost REAL NOT NULL DEFAULT 0, + discount_percent REAL NOT NULL DEFAULT 0, + quantity REAL NOT NULL DEFAULT 1, + line_total REAL NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (quotation_id) REFERENCES crm_quotations(id) + )""", + "CREATE INDEX IF NOT EXISTS idx_crm_quotations_customer ON crm_quotations(customer_id)", + "CREATE INDEX IF NOT EXISTS idx_crm_quotation_items_quotation ON crm_quotation_items(quotation_id, sort_order)", ] @@ -86,6 +182,65 @@ async def init_db(): for stmt in SCHEMA_STATEMENTS: await _db.execute(stmt) await _db.commit() + # Migrations: add columns that may not exist in older DBs + _migrations = [ + "ALTER TABLE crm_comms_log ADD COLUMN body_html TEXT", + "ALTER TABLE crm_comms_log ADD COLUMN mail_account TEXT", + "ALTER TABLE crm_comms_log ADD COLUMN from_addr TEXT", + "ALTER TABLE crm_comms_log ADD COLUMN to_addrs TEXT", + "ALTER TABLE crm_comms_log ADD COLUMN is_important INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE crm_comms_log ADD COLUMN is_read INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE crm_quotation_items ADD COLUMN vat_percent REAL NOT NULL DEFAULT 24", + "ALTER TABLE crm_quotations ADD COLUMN quick_notes TEXT NOT NULL DEFAULT '{}'", + "ALTER TABLE crm_quotations ADD COLUMN client_org TEXT", + "ALTER TABLE crm_quotations ADD COLUMN client_name TEXT", + "ALTER TABLE crm_quotations ADD COLUMN client_location TEXT", + "ALTER TABLE crm_quotations ADD COLUMN client_phone TEXT", + "ALTER TABLE crm_quotations ADD COLUMN client_email TEXT", + ] + for m in _migrations: + try: + await _db.execute(m) + await _db.commit() + except Exception: + pass # column already exists + + # Migration: drop NOT NULL on crm_comms_log.customer_id if it exists. + # SQLite doesn't support ALTER COLUMN, so we check via table_info and + # rebuild the table if needed. + rows = await _db.execute_fetchall("PRAGMA table_info(crm_comms_log)") + for row in rows: + # row: (cid, name, type, notnull, dflt_value, pk) + if row[1] == "customer_id" and row[3] == 1: # notnull=1 + logger.info("Migrating crm_comms_log: removing NOT NULL from customer_id") + await _db.execute("ALTER TABLE crm_comms_log RENAME TO crm_comms_log_old") + await _db.execute("""CREATE TABLE crm_comms_log ( + id TEXT PRIMARY KEY, + customer_id TEXT, + type TEXT NOT NULL, + mail_account TEXT, + direction TEXT NOT NULL, + subject TEXT, + body TEXT, + body_html TEXT, + attachments TEXT NOT NULL DEFAULT '[]', + ext_message_id TEXT, + from_addr TEXT, + to_addrs TEXT, + logged_by TEXT, + occurred_at TEXT NOT NULL, + created_at TEXT NOT NULL + )""") + await _db.execute("""INSERT INTO crm_comms_log + SELECT id, customer_id, type, NULL, direction, subject, body, body_html, + attachments, ext_message_id, from_addr, to_addrs, logged_by, + occurred_at, created_at + FROM crm_comms_log_old""") + await _db.execute("DROP TABLE crm_comms_log_old") + await _db.execute("CREATE INDEX IF NOT EXISTS idx_crm_comms_customer ON crm_comms_log(customer_id, occurred_at)") + await _db.commit() + logger.info("Migration complete: crm_comms_log.customer_id is now nullable") + break logger.info(f"SQLite database initialized at {settings.sqlite_db_path}") @@ -252,3 +407,37 @@ async def purge_loop(): await purge_old_data() except Exception as e: logger.error(f"Purge failed: {e}") + + +# --- Device Alerts --- + +async def upsert_alert(device_serial: str, subsystem: str, state: str, + message: str | None = None): + db = await get_db() + await db.execute( + """INSERT INTO device_alerts (device_serial, subsystem, state, message, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(device_serial, subsystem) + DO UPDATE SET state=excluded.state, message=excluded.message, + updated_at=excluded.updated_at""", + (device_serial, subsystem, state, message), + ) + await db.commit() + + +async def delete_alert(device_serial: str, subsystem: str): + db = await get_db() + await db.execute( + "DELETE FROM device_alerts WHERE device_serial = ? AND subsystem = ?", + (device_serial, subsystem), + ) + await db.commit() + + +async def get_alerts(device_serial: str) -> list: + db = await get_db() + rows = await db.execute_fetchall( + "SELECT * FROM device_alerts WHERE device_serial = ? ORDER BY updated_at DESC", + (device_serial,), + ) + return [dict(r) for r in rows] diff --git a/backend/mqtt/logger.py b/backend/mqtt/logger.py index 53f2019..a4860ba 100644 --- a/backend/mqtt/logger.py +++ b/backend/mqtt/logger.py @@ -18,6 +18,10 @@ async def handle_message(serial: str, topic_type: str, payload: dict): try: if topic_type == "status/heartbeat": await _handle_heartbeat(serial, payload) + elif topic_type == "status/alerts": + await _handle_alerts(serial, payload) + elif topic_type == "status/info": + await _handle_info(serial, payload) elif topic_type == "logs": await _handle_log(serial, payload) elif topic_type == "data": @@ -29,6 +33,8 @@ async def handle_message(serial: str, topic_type: str, payload: dict): async def _handle_heartbeat(serial: str, payload: dict): + # Store silently — do not log as a visible event. + # The console surfaces an alert only when the device goes silent (no heartbeat for 90s). inner = payload.get("payload", {}) await db.insert_heartbeat( device_serial=serial, @@ -55,6 +61,31 @@ async def _handle_log(serial: str, payload: dict): ) +async def _handle_alerts(serial: str, payload: dict): + subsystem = payload.get("subsystem", "") + state = payload.get("state", "") + if not subsystem or not state: + logger.warning(f"Malformed alert payload from {serial}: {payload}") + return + + if state == "CLEARED": + await db.delete_alert(serial, subsystem) + else: + await db.upsert_alert(serial, subsystem, state, payload.get("msg")) + + +async def _handle_info(serial: str, payload: dict): + event_type = payload.get("type", "") + data = payload.get("payload", {}) + + if event_type == "playback_started": + logger.debug(f"{serial}: playback started — melody_uid={data.get('melody_uid')}") + elif event_type == "playback_stopped": + logger.debug(f"{serial}: playback stopped") + else: + logger.debug(f"{serial}: info event '{event_type}'") + + async def _handle_data_response(serial: str, payload: dict): status = payload.get("status", "") diff --git a/backend/mqtt/models.py b/backend/mqtt/models.py index 0336a26..4a11451 100644 --- a/backend/mqtt/models.py +++ b/backend/mqtt/models.py @@ -84,3 +84,15 @@ class CommandSendResponse(BaseModel): success: bool command_id: int message: str + + +class DeviceAlertEntry(BaseModel): + device_serial: str + subsystem: str + state: str + message: Optional[str] = None + updated_at: str + + +class DeviceAlertsResponse(BaseModel): + alerts: List[DeviceAlertEntry] diff --git a/backend/requirements.txt b/backend/requirements.txt index 9ab2c38..a61b4cc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,4 +9,7 @@ passlib[bcrypt]==1.7.4 python-multipart==0.0.20 bcrypt==4.0.1 aiosqlite==0.20.0 -resend==2.10.0 \ No newline at end of file +resend==2.10.0 +httpx>=0.27.0 +weasyprint>=62.0 +jinja2>=3.1.0 \ No newline at end of file diff --git a/backend/storage/product_images/5d3873c0-1600-4c3b-a4e5-595e8fe19c8c.png b/backend/storage/product_images/5d3873c0-1600-4c3b-a4e5-595e8fe19c8c.png new file mode 100644 index 0000000000000000000000000000000000000000..753f43571276656d69f5993aa56ee28c6b8ce281 GIT binary patch literal 21894 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 Rwr6z~PQ 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 literal 0 HcmV?d00001 diff --git a/backend/templates/logo.png b/backend/templates/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7c969cde489fc7da2065290c114c004c7573bd65 GIT binary patch literal 14520 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 VXIn2G>{UcK6$MTCdRfbe{|7uG38Vl3 literal 0 HcmV?d00001 diff --git a/backend/templates/quotation.html b/backend/templates/quotation.html new file mode 100644 index 0000000..268a6e9 --- /dev/null +++ b/backend/templates/quotation.html @@ -0,0 +1,708 @@ + + + + +{% if lang == 'gr' %}Προσφορά{% else %}Quotation{% endif %} {{ quotation.quotation_number }} + + + + +{# ── Bilingual labels ── #} +{% if lang == 'gr' %} + {% set L_QUOTATION = "ΠΡΟΣΦΟΡΑ" %} + {% set L_NUMBER = "Αριθμός" %} + {% set L_DATE = "Ημερομηνία" %} + {% set L_CLIENT = "ΣΤΟΙΧΕΙΑ ΠΕΛΑΤΗ" %} + {% set L_ORDER_META = "ΣΤΟΙΧΕΙΑ ΠΑΡΑΓΓΕΛΙΑΣ" %} + {% set L_ORDER_TYPE = "Τύπος" %} + {% set L_SHIP_METHOD = "Τρ. Αποστολής" %} + {% set L_SHIP_DATE = "Εκτιμώμενη Παράδοση" %} + {% set L_DESC = "Περιγραφή" %} + {% set L_UNIT_COST = "Τιμή Μον." %} + {% set L_DISC = "Έκπτ." %} + {% set L_QTY = "Ποσ." %} + {% set L_UNIT = "Μον." %} + {% set L_VAT_COL = "Φ.Π.Α." %} + {% set L_TOTAL = "Σύνολο" %} + {% set L_SUBTOTAL = "Υποσύνολο" %} + {% set L_GLOBAL_DISC = quotation.global_discount_label or "Έκπτωση" %} + {% set L_NEW_SUBTOTAL = "Νέο Υποσύνολο" %} + {% set L_VAT = "ΣΥΝΟΛΟ Φ.Π.Α." %} + {% set L_SHIPPING_COST = "Μεταφορικά / Shipping" %} + {% set L_INSTALL_COST = "Εγκατάσταση / Installation" %} + {% set L_EXTRAS = quotation.extras_label or "Άλλα" %} + {% set L_FINAL = "ΣΥΝΟΛΟ ΠΛΗΡΩΤΕΟ" %} + {% set L_COMMENTS = "ΣΗΜΕΙΩΣΕΙΣ" %} + {% set L_VALIDITY = "Η προσφορά ισχύει για 30 ημέρες από την ημερομηνία έκδοσής της." %} + {% set L_ORG = "Φορέας" %} + {% set L_CONTACT = "Επικοινωνία" %} + {% set L_ADDRESS = "Διεύθυνση" %} + {% set L_PHONE = "Τηλέφωνο" %} + {% set L_COMPANY_ADDR = "Ε.Ο. Αντιρρίου Ιωαννίνων 23, Αγρίνιο, 30131" %} + {% set L_CONTACT_INFO = "ΣΤΟΙΧΕΙΑ ΕΠΙΚΟΙΝΩΝΙΑΣ" %} + {% set L_PAYMENT_INFO = "ΣΤΟΙΧΕΙΑ ΠΛΗΡΩΜΗΣ" %} +{% else %} + {% set L_QUOTATION = "QUOTATION" %} + {% set L_NUMBER = "Number" %} + {% set L_DATE = "Date" %} + {% set L_CLIENT = "CLIENT DETAILS" %} + {% set L_ORDER_META = "ORDER DETAILS" %} + {% set L_ORDER_TYPE = "Order Type" %} + {% set L_SHIP_METHOD = "Ship. Method" %} + {% set L_SHIP_DATE = "Est. Delivery" %} + {% set L_DESC = "Description" %} + {% set L_UNIT_COST = "Unit Cost" %} + {% set L_DISC = "Disc." %} + {% set L_QTY = "Qty" %} + {% set L_UNIT = "Unit" %} + {% set L_VAT_COL = "VAT" %} + {% set L_TOTAL = "Total" %} + {% set L_SUBTOTAL = "Subtotal" %} + {% set L_GLOBAL_DISC = quotation.global_discount_label or "Discount" %} + {% set L_NEW_SUBTOTAL = "New Subtotal" %} + {% set L_VAT = "Total VAT" %} + {% set L_SHIPPING_COST = "Shipping / Transport" %} + {% set L_INSTALL_COST = "Installation" %} + {% set L_EXTRAS = quotation.extras_label or "Extras" %} + {% set L_FINAL = "TOTAL DUE" %} + {% set L_COMMENTS = "NOTES" %} + {% set L_VALIDITY = "This quotation is valid for 30 days from the date of issue." %} + {% set L_ORG = "Organization" %} + {% set L_CONTACT = "Contact" %} + {% set L_ADDRESS = "Location" %} + {% set L_PHONE = "Phone" %} + {% set L_COMPANY_ADDR = "E.O. Antirriou Ioanninon 23, Agrinio, 30131, Greece" %} + {% set L_CONTACT_INFO = "CONTACT INFORMATION" %} + {% set L_PAYMENT_INFO = "PAYMENT DETAILS" %} +{% endif %} + +{# ── Derived values ── #} +{% set today = quotation.created_at[:10] %} + +{# ── Find phone/email contacts + check if primary contact is already phone/email ── #} +{% set ns = namespace(customer_phone='', customer_email='', primary_is_phone=false, primary_is_email=false) %} +{% for contact in customer.contacts %} + {% if contact.type == 'phone' and contact.value %}{% if contact.primary %}{% set ns.customer_phone = contact.value %}{% set ns.primary_is_phone = true %}{% elif not ns.customer_phone %}{% set ns.customer_phone = contact.value %}{% endif %}{% endif %} + {% if contact.type == 'email' and contact.value %}{% if contact.primary %}{% set ns.customer_email = contact.value %}{% set ns.primary_is_email = true %}{% elif not ns.customer_email %}{% set ns.customer_email = contact.value %}{% endif %}{% endif %} +{% endfor %} +{% set customer_phone = ns.customer_phone %} +{% set customer_email = ns.customer_email %} +{% set primary_is_phone = ns.primary_is_phone %} +{% set primary_is_email = ns.primary_is_email %} + + +

+
+ +

{{ L_COMPANY_ADDR }}

+
+
+
{{ L_QUOTATION }}
+
{{ L_NUMBER }}: {{ quotation.quotation_number }}
+
{{ L_DATE }}: {{ today }}
+
+
+ + +{% if quotation.title %} +
+

{{ quotation.title }}

+ {% if quotation.subtitle %}

{{ quotation.subtitle }}

{% endif %} +
+{% endif %} + + +
+ +
+
{{ L_CLIENT }}
+ {% if customer.organization %}{% endif %}{% set name_parts = [customer.title, customer.name, customer.surname] | select | list %}{% if name_parts %}{% endif %}{% if customer.location %}{% set loc_parts = [customer.location.city, customer.location.region, customer.location.country] | select | list %}{% if loc_parts %}{% endif %}{% endif %}{% if customer_email %}{% endif %}{% if customer_phone %}{% endif %}
{{ L_ORG }}{{ customer.organization }}
{{ L_CONTACT }}{{ name_parts | join(' ') }}
{{ L_ADDRESS }}{{ loc_parts | join(', ') }}
Email{{ customer_email }}
{{ L_PHONE }}{{ customer_phone }}
+
+ +
+
{{ L_ORDER_META }}
+ {% if quotation.order_type %}{% endif %}{% if quotation.shipping_method %}{% endif %}{% if quotation.estimated_shipping_date %}{% else %}{% endif %}
{{ L_ORDER_TYPE }}{{ quotation.order_type }}
{{ L_SHIP_METHOD }}{{ quotation.shipping_method }}
{{ L_SHIP_DATE }}{{ quotation.estimated_shipping_date }}
{{ L_SHIP_DATE }}
+
+ +
+ + + + + + + + + + + + + + + + {% for item in quotation.items %} + + + + + + + + + + {% endfor %} + {% if quotation.items | length == 0 %} + + + + {% endif %} + + {# ── Shipping / Install as special rows ── #} + {% set has_special = (quotation.shipping_cost and quotation.shipping_cost > 0) or (quotation.install_cost and quotation.install_cost > 0) %} + {% if has_special %} + + {% endif %} + + {% if quotation.shipping_cost and quotation.shipping_cost > 0 %} + {% set ship_net = quotation.shipping_cost * (1 - quotation.shipping_cost_discount / 100) %} + + + + + + + + + + {% endif %} + + {% if quotation.install_cost and quotation.install_cost > 0 %} + {% set install_net = quotation.install_cost * (1 - quotation.install_cost_discount / 100) %} + + + + + + + + + + {% endif %} + + +
{{ L_DESC }}{{ L_UNIT_COST }}{{ L_DISC }}{{ L_QTY }}{{ L_UNIT }}{{ L_VAT_COL }}{{ L_TOTAL }}
{{ item.description or '' }}{{ item.unit_cost | format_money }} + {% if item.discount_percent and item.discount_percent > 0 %} + {{ item.discount_percent | int }}% + {% else %} + + {% endif %} + {{ item.quantity | int if item.quantity == (item.quantity | int) else item.quantity }}{{ item.unit_type }} + {% if item.vat_percent and item.vat_percent > 0 %} + {{ item.vat_percent | int }}% + {% else %} + + {% endif %} + {{ item.line_total | format_money }}
{{ L_SHIPPING_COST }}{% if quotation.shipping_cost_discount and quotation.shipping_cost_discount > 0 %} (-{{ quotation.shipping_cost_discount | int }}%){% endif %}{{ quotation.shipping_cost | format_money }}1{{ ship_net | format_money }}
{{ L_INSTALL_COST }}{% if quotation.install_cost_discount and quotation.install_cost_discount > 0 %} (-{{ quotation.install_cost_discount | int }}%){% endif %}{{ quotation.install_cost | format_money }}1{{ install_net | format_money }}
+ + +
+ +
+
+ + + + + + + {% if quotation.global_discount_percent and quotation.global_discount_percent > 0 %} + + + + + + + + + {% endif %} + + + + + {% if quotation.extras_cost and quotation.extras_cost > 0 %} + + + + + {% endif %} + + + + +
{{ L_SUBTOTAL }}{{ quotation.subtotal_before_discount | format_money }}
{{ L_GLOBAL_DISC }} ({{ quotation.global_discount_percent | int }}%)- {{ quotation.global_discount_amount | format_money }}
{{ L_NEW_SUBTOTAL }}{{ quotation.new_subtotal | format_money }}
{{ L_VAT }}{{ quotation.vat_amount | format_money }}
{{ L_EXTRAS }}{{ quotation.extras_cost | format_money }}
{{ L_FINAL }}{{ quotation.final_total | format_money }}
+ +
+ + +
+ + +{% set qn = quotation.quick_notes or {} %} +{% set has_quick = (qn.payment_advance and qn.payment_advance.enabled) or (qn.lead_time and qn.lead_time.enabled) or (qn.backup_relays and qn.backup_relays.enabled) %} +{% set has_comments = quotation.comments and quotation.comments | length > 0 %} + +{% if has_quick or has_comments %} +
+
{{ L_COMMENTS }}
+
    + + {# ── Quick Notes ── #} + + {# Payment Advance #} + {% if qn.payment_advance and qn.payment_advance.enabled %} + {% set pct = qn.payment_advance.percent | string %} + {% if lang == 'gr' %} +
  • Απαιτείται προκαταβολή {{ pct }}% με την επιβεβαίωση της παραγγελίας.
  • + {% else %} +
  • {{ pct }}% advance payment is required upon order confirmation.
  • + {% endif %} + {% endif %} + + {# Lead Time #} + {% if qn.lead_time and qn.lead_time.enabled %} + {% set days = qn.lead_time.days | string %} + {% if lang == 'gr' %} +
  • Εκτιμώμενος χρόνος παράδοσης, {{ days }} εργάσιμες ημέρες από την επιβεβαίωση της παραγγελίας και παραλαβή της προκαταβολής.
  • + {% else %} +
  • Estimated delivery time is {{ days }} working days from order confirmation and receipt of advance payment.
  • + {% endif %} + {% endif %} + + {# Backup Relays #} + {% if qn.backup_relays and qn.backup_relays.enabled %} + {% set n = qn.backup_relays.count | int %} + {% if lang == 'gr' %} + {% if n == 1 %} +
  • Συμπεριλαμβάνονται: {{ n }} έξτρα Εφεδρικό Ρελέ Ισχύος
  • + {% else %} +
  • Συμπεριλαμβάνονται: {{ n }} έξτρα Εφεδρικά Ρελέ Ισχύος
  • + {% endif %} + {% else %} + {% if n == 1 %} +
  • {{ n }} Extra Relay included as Backup, free of charge.
  • + {% else %} +
  • {{ n }} Extra Relays included as Backups, free of charge.
  • + {% endif %} + {% endif %} + {% endif %} + + {# ── Dynamic comments ── #} + {% if has_comments %} + {% for comment in quotation.comments %} + {% if comment and comment.strip() %} +
  • {{ comment }}
  • + {% endif %} + {% endfor %} + {% endif %} + +
+
+{% endif %} + + + + + + + + + diff --git a/backend/utils/nvs_generator.py b/backend/utils/nvs_generator.py index 80bf95e..58f68e9 100644 --- a/backend/utils/nvs_generator.py +++ b/backend/utils/nvs_generator.py @@ -181,7 +181,7 @@ def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes: """Generate a 0x5000-byte NVS partition binary for a Vesper device. serial_number: full SN string e.g. 'PV-26B27-VS01R-X7KQA' - hw_type: lowercase board type e.g. 'vs', 'vp', 'vx' + hw_type: board type e.g. 'vesper', 'vesper_plus', 'vesper_pro' hw_version: zero-padded version e.g. '01' Returns raw bytes ready to flash at 0x9000. diff --git a/frontend/index.html b/frontend/index.html index 8ebeb72..d29c29f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + BellSystems Admin diff --git a/frontend/public/favicon-96x96.png b/frontend/public/favicon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..0a51a6a7e3ee3c45340c1a15c98ae64fc867dc5a GIT binary patch literal 7035 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 \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f7b40c6..d002253 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -30,6 +30,13 @@ import DeviceInventoryDetail from "./manufacturing/DeviceInventoryDetail"; import ProvisioningWizard from "./manufacturing/ProvisioningWizard"; import FirmwareManager from "./firmware/FirmwareManager"; import DashboardPage from "./dashboard/DashboardPage"; +import ApiReferencePage from "./developer/ApiReferencePage"; +import { ProductList, ProductForm } from "./crm/products"; +import { CustomerList, CustomerForm, CustomerDetail } from "./crm/customers"; +import { OrderList, OrderForm, OrderDetail } from "./crm/orders"; +import { QuotationForm } from "./crm/quotations"; +import CommsPage from "./crm/inbox/CommsPage"; +import MailPage from "./crm/mail/MailPage"; function ProtectedRoute({ children }) { const { user, loading } = useAuth(); @@ -150,6 +157,30 @@ export default function App() { } /> } /> + {/* Mail */} + } /> + + {/* CRM */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Developer */} + {/* TODO: replace RoleGate with a dedicated "developer" permission once granular permissions are implemented */} + } /> + {/* Settings - Staff Management */} } /> } /> diff --git a/frontend/src/assets/comms/call.svg b/frontend/src/assets/comms/call.svg new file mode 100644 index 0000000..1b0ee0f --- /dev/null +++ b/frontend/src/assets/comms/call.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/comms/email.svg b/frontend/src/assets/comms/email.svg new file mode 100644 index 0000000..fda10f6 --- /dev/null +++ b/frontend/src/assets/comms/email.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/comms/inbound.svg b/frontend/src/assets/comms/inbound.svg new file mode 100644 index 0000000..2828dbc --- /dev/null +++ b/frontend/src/assets/comms/inbound.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/comms/inperson.svg b/frontend/src/assets/comms/inperson.svg new file mode 100644 index 0000000..6260539 --- /dev/null +++ b/frontend/src/assets/comms/inperson.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/comms/internal.svg b/frontend/src/assets/comms/internal.svg new file mode 100644 index 0000000..44a85e9 --- /dev/null +++ b/frontend/src/assets/comms/internal.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/comms/mail.svg b/frontend/src/assets/comms/mail.svg new file mode 100644 index 0000000..d663ee2 --- /dev/null +++ b/frontend/src/assets/comms/mail.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/comms/note.svg b/frontend/src/assets/comms/note.svg new file mode 100644 index 0000000..5a87aee --- /dev/null +++ b/frontend/src/assets/comms/note.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/comms/outbound.svg b/frontend/src/assets/comms/outbound.svg new file mode 100644 index 0000000..1b26b9f --- /dev/null +++ b/frontend/src/assets/comms/outbound.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/comms/sms.svg b/frontend/src/assets/comms/sms.svg new file mode 100644 index 0000000..5f55c9c --- /dev/null +++ b/frontend/src/assets/comms/sms.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/comms/whatsapp.svg b/frontend/src/assets/comms/whatsapp.svg new file mode 100644 index 0000000..6595af8 --- /dev/null +++ b/frontend/src/assets/comms/whatsapp.svg @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/auth/AuthContext.jsx b/frontend/src/auth/AuthContext.jsx index a73ab61..e32c469 100644 --- a/frontend/src/auth/AuthContext.jsx +++ b/frontend/src/auth/AuthContext.jsx @@ -71,6 +71,25 @@ export function AuthProvider({ children }) { return roles.includes(user.role); }; + /** + * hasPermission(section, action) + * + * Sections and their action keys: + * melodies: view, add, delete, safe_edit, full_edit, archetype_access, settings_access, compose_access + * devices: view, add, delete, safe_edit, edit_bells, edit_clock, edit_warranty, full_edit, control + * app_users: view, add, delete, safe_edit, full_edit + * issues_notes: view, add, delete, edit + * mail: view, compose, reply + * crm: activity_log + * crm_customers: full_access, overview, orders_view, orders_edit, quotations_view, quotations_edit, + * comms_view, comms_log, comms_edit, comms_compose, add, delete, + * files_view, files_edit, devices_view, devices_edit + * crm_orders: view (→ crm_customers.orders_view), edit (→ crm_customers.orders_edit) [derived] + * crm_products: view, add, edit + * mfg: view_inventory, edit, provision, firmware_view, firmware_edit + * api_reference: access + * mqtt: access + */ const hasPermission = (section, action) => { if (!user) return false; // sysadmin and admin have full access @@ -79,13 +98,22 @@ export function AuthProvider({ children }) { const perms = user.permissions; if (!perms) return false; - // MQTT is a global flag - if (section === "mqtt") { - return !!perms.mqtt; + // crm_orders is derived from crm_customers + if (section === "crm_orders") { + const cc = perms.crm_customers; + if (!cc) return false; + if (cc.full_access) return true; + if (action === "view") return !!cc.orders_view; + if (action === "edit") return !!cc.orders_edit; + return false; } const sectionPerms = perms[section]; if (!sectionPerms) return false; + + // crm_customers.full_access grants everything in that section + if (section === "crm_customers" && sectionPerms.full_access) return true; + return !!sectionPerms[action]; }; diff --git a/frontend/src/crm/components/CommIcons.jsx b/frontend/src/crm/components/CommIcons.jsx new file mode 100644 index 0000000..bf53629 --- /dev/null +++ b/frontend/src/crm/components/CommIcons.jsx @@ -0,0 +1,141 @@ +import emailIconRaw from "../../assets/comms/email.svg?raw"; +import inpersonIconRaw from "../../assets/comms/inperson.svg?raw"; +import noteIconRaw from "../../assets/comms/note.svg?raw"; +import smsIconRaw from "../../assets/comms/sms.svg?raw"; +import whatsappIconRaw from "../../assets/comms/whatsapp.svg?raw"; +import callIconRaw from "../../assets/comms/call.svg?raw"; +import inboundIconRaw from "../../assets/comms/inbound.svg?raw"; +import outboundIconRaw from "../../assets/comms/outbound.svg?raw"; +import internalIconRaw from "../../assets/comms/internal.svg?raw"; + +const TYPE_TONES = { + email: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }, + whatsapp: { bg: "#dcfce7", color: "#166534" }, + call: { bg: "#fef9c3", color: "#854d0e" }, + sms: { bg: "#fef3c7", color: "#92400e" }, + note: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" }, + in_person: { bg: "#ede9fe", color: "#5b21b6" }, +}; + +const DIR_TONES = { + inbound: { bg: "#2c1a1a", color: "#ef4444", title: "Inbound" }, + outbound: { bg: "#13261a", color: "#16a34a", title: "Outbound" }, + internal: { bg: "#102335", color: "#4dabf7", title: "Internal" }, +}; + +function IconWrap({ title, bg, color, size = 22, children }) { + return ( + + {children} + + ); +} + +function InlineRawSvg({ raw, size = 12, forceRootFill = true }) { + if (!raw) return null; + let normalized = raw + .replace(/<\?xml[\s\S]*?\?>/gi, "") + .replace(//gi, "") + .replace(/#000000/gi, "currentColor") + .replace(/#000\b/gi, "currentColor") + .replace(/\sfill="(?!none|currentColor|url\()[^"]*"/gi, ' fill="currentColor"') + .replace(/\sstroke="(?!none|currentColor|url\()[^"]*"/gi, ' stroke="currentColor"') + .replace(/fill\s*:\s*(?!none|currentColor|url\()[^;"]+/gi, "fill:currentColor") + .replace(/stroke\s*:\s*(?!none|currentColor|url\()[^;"]+/gi, "stroke:currentColor"); + normalized = forceRootFill + ? normalized.replace(/