From d0ac4f1d911bad36aa63701b23c7597fb2170113 Mon Sep 17 00:00:00 2001 From: bonamin Date: Wed, 18 Mar 2026 17:49:40 +0200 Subject: [PATCH] update: overhauled firmware ui. Added public flash page. --- backend/builder/database.py | 27 +- backend/builder/models.py | 3 + backend/builder/router.py | 20 +- backend/builder/service.py | 137 +- backend/database/core.py | 1 + backend/devices/models.py | 21 + backend/devices/router.py | 384 +++- backend/devices/service.py | 45 +- backend/equipment/service.py | 2 +- backend/firmware/models.py | 8 +- backend/firmware/router.py | 51 +- backend/firmware/service.py | 190 +- backend/main.py | 2 + backend/manufacturing/models.py | 22 +- backend/manufacturing/router.py | 186 +- backend/manufacturing/service.py | 151 +- backend/melodies/models.py | 1 + backend/melodies/router.py | 17 + backend/public/__init__.py | 0 backend/public/router.py | 208 ++ backend/settings/public_features_models.py | 10 + backend/settings/public_features_service.py | 31 + backend/settings/router.py | 22 +- backend/utils/nvs_generator.py | 14 +- frontend/src/App.jsx | 8 + .../src/assets/logos/cloudflash_large.png | Bin 0 -> 105111 bytes .../src/assets/logos/cloudflash_small.png | Bin 0 -> 12409 bytes frontend/src/cloudflash/CloudFlashPage.jsx | 1053 ++++++++++ frontend/src/crm/customers/CustomerDetail.jsx | 2 +- frontend/src/devices/DeviceDetail.jsx | 687 ++++++- frontend/src/devices/DeviceList.jsx | 37 +- frontend/src/firmware/FirmwareManager.jsx | 1758 +++++++++++------ frontend/src/layout/Sidebar.jsx | 5 +- .../src/manufacturing/DeviceInventory.jsx | 287 ++- .../manufacturing/DeviceInventoryDetail.jsx | 1102 ++++++++--- .../src/manufacturing/ProvisioningWizard.jsx | 284 ++- frontend/src/melodies/MelodyComposer.jsx | 143 +- frontend/src/melodies/MelodyDetail.jsx | 60 +- frontend/src/melodies/MelodyForm.jsx | 15 +- frontend/src/melodies/MelodyList.jsx | 242 +-- .../src/melodies/archetypes/ArchetypeForm.jsx | 297 +-- .../src/melodies/archetypes/ArchetypeList.jsx | 853 +++++--- .../archetypes/BuildOnTheFlyModal.jsx | 1 + .../archetypes/SelectArchetypeModal.jsx | 1 + .../src/settings/PublicFeaturesSettings.jsx | 133 ++ 45 files changed, 6798 insertions(+), 1723 deletions(-) create mode 100644 backend/public/__init__.py create mode 100644 backend/public/router.py create mode 100644 backend/settings/public_features_models.py create mode 100644 backend/settings/public_features_service.py create mode 100644 frontend/src/assets/logos/cloudflash_large.png create mode 100644 frontend/src/assets/logos/cloudflash_small.png create mode 100644 frontend/src/cloudflash/CloudFlashPage.jsx create mode 100644 frontend/src/settings/PublicFeaturesSettings.jsx diff --git a/backend/builder/database.py b/backend/builder/database.py index e2678c3..9bb8498 100644 --- a/backend/builder/database.py +++ b/backend/builder/database.py @@ -5,23 +5,34 @@ from database import get_db logger = logging.getLogger("builder.database") -async def insert_built_melody(melody_id: str, name: str, pid: str, steps: str) -> None: +async def insert_built_melody(melody_id: str, name: str, pid: str, steps: str, is_builtin: bool = False) -> None: db = await get_db() await db.execute( - """INSERT INTO built_melodies (id, name, pid, steps, assigned_melody_ids) - VALUES (?, ?, ?, ?, ?)""", - (melody_id, name, pid, steps, json.dumps([])), + """INSERT INTO built_melodies (id, name, pid, steps, assigned_melody_ids, is_builtin) + VALUES (?, ?, ?, ?, ?, ?)""", + (melody_id, name, pid, steps, json.dumps([]), 1 if is_builtin else 0), ) await db.commit() -async def update_built_melody(melody_id: str, name: str, pid: str, steps: str) -> None: +async def update_built_melody(melody_id: str, name: str, pid: str, steps: str, is_builtin: bool = False) -> None: db = await get_db() await db.execute( """UPDATE built_melodies - SET name = ?, pid = ?, steps = ?, updated_at = datetime('now') + SET name = ?, pid = ?, steps = ?, is_builtin = ?, updated_at = datetime('now') WHERE id = ?""", - (name, pid, steps, melody_id), + (name, pid, steps, 1 if is_builtin else 0, melody_id), + ) + await db.commit() + + +async def update_builtin_flag(melody_id: str, is_builtin: bool) -> None: + db = await get_db() + await db.execute( + """UPDATE built_melodies + SET is_builtin = ?, updated_at = datetime('now') + WHERE id = ?""", + (1 if is_builtin else 0, melody_id), ) await db.commit() @@ -68,6 +79,7 @@ async def get_built_melody(melody_id: str) -> dict | None: return None row = dict(rows[0]) row["assigned_melody_ids"] = json.loads(row["assigned_melody_ids"] or "[]") + row["is_builtin"] = bool(row.get("is_builtin", 0)) return row @@ -80,6 +92,7 @@ async def list_built_melodies() -> list[dict]: for row in rows: r = dict(row) r["assigned_melody_ids"] = json.loads(r["assigned_melody_ids"] or "[]") + r["is_builtin"] = bool(r.get("is_builtin", 0)) results.append(r) return results diff --git a/backend/builder/models.py b/backend/builder/models.py index 6c6f2d5..74ab282 100644 --- a/backend/builder/models.py +++ b/backend/builder/models.py @@ -6,12 +6,14 @@ class BuiltMelodyCreate(BaseModel): name: str pid: str steps: str # raw step string e.g. "1,2,2+1,1,2,3+1" + is_builtin: bool = False class BuiltMelodyUpdate(BaseModel): name: Optional[str] = None pid: Optional[str] = None steps: Optional[str] = None + is_builtin: Optional[bool] = None class BuiltMelodyInDB(BaseModel): @@ -19,6 +21,7 @@ class BuiltMelodyInDB(BaseModel): name: str pid: str steps: str + is_builtin: bool = False binary_path: Optional[str] = None binary_url: Optional[str] = None progmem_code: Optional[str] = None diff --git a/backend/builder/router.py b/backend/builder/router.py index b6d6596..abc1e9e 100644 --- a/backend/builder/router.py +++ b/backend/builder/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, PlainTextResponse from auth.models import TokenPayload from auth.dependencies import require_permission from builder.models import ( @@ -20,6 +20,7 @@ async def list_built_melodies( melodies = await service.list_built_melodies() return BuiltMelodyListResponse(melodies=melodies, total=len(melodies)) + @router.get("/for-melody/{firestore_melody_id}") async def get_for_firestore_melody( firestore_melody_id: str, @@ -32,6 +33,14 @@ async def get_for_firestore_melody( return result.model_dump() +@router.get("/generate-builtin-list") +async def generate_builtin_list( + _user: TokenPayload = Depends(require_permission("melodies", "view")), +): + """Generate a C++ header with PROGMEM arrays for all is_builtin archetypes.""" + code = await service.generate_builtin_list() + return PlainTextResponse(content=code, media_type="text/plain") + @router.get("/{melody_id}", response_model=BuiltMelodyInDB) async def get_built_melody( @@ -66,6 +75,15 @@ async def delete_built_melody( await service.delete_built_melody(melody_id) +@router.post("/{melody_id}/toggle-builtin", response_model=BuiltMelodyInDB) +async def toggle_builtin( + melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "edit")), +): + """Toggle the is_builtin flag for an archetype.""" + return await service.toggle_builtin(melody_id) + + @router.post("/{melody_id}/build-binary", response_model=BuiltMelodyInDB) async def build_binary( melody_id: str, diff --git a/backend/builder/service.py b/backend/builder/service.py index c20f7c4..cdd968c 100644 --- a/backend/builder/service.py +++ b/backend/builder/service.py @@ -32,6 +32,7 @@ def _row_to_built_melody(row: dict) -> BuiltMelodyInDB: name=row["name"], pid=row["pid"], steps=row["steps"], + is_builtin=row.get("is_builtin", False), binary_path=binary_path, binary_url=binary_url, progmem_code=row.get("progmem_code"), @@ -151,8 +152,12 @@ async def create_built_melody(data: BuiltMelodyCreate) -> BuiltMelodyInDB: name=data.name, pid=data.pid, steps=data.steps, + is_builtin=data.is_builtin, ) - return await get_built_melody(melody_id) + # Auto-build binary and builtin code on creation + result = await get_built_melody(melody_id) + result = await _do_build(melody_id) + return result async def update_built_melody(melody_id: str, data: BuiltMelodyUpdate) -> BuiltMelodyInDB: @@ -163,11 +168,22 @@ async def update_built_melody(melody_id: str, data: BuiltMelodyUpdate) -> BuiltM new_name = data.name if data.name is not None else row["name"] new_pid = data.pid if data.pid is not None else row["pid"] new_steps = data.steps if data.steps is not None else row["steps"] + new_is_builtin = data.is_builtin if data.is_builtin is not None else row.get("is_builtin", False) await _check_unique(new_name, new_pid or "", exclude_id=melody_id) - await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps) - return await get_built_melody(melody_id) + steps_changed = (data.steps is not None) and (data.steps != row["steps"]) + + await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps, is_builtin=new_is_builtin) + + # If steps changed, flag all assigned melodies as outdated, then rebuild + if steps_changed: + assigned_ids = row.get("assigned_melody_ids", []) + if assigned_ids: + await _flag_melodies_outdated(assigned_ids, True) + + # Auto-rebuild binary and builtin code on every save + return await _do_build(melody_id) async def delete_built_melody(melody_id: str) -> None: @@ -175,6 +191,11 @@ async def delete_built_melody(melody_id: str) -> None: if not row: raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found") + # Flag all assigned melodies as outdated before deleting + assigned_ids = row.get("assigned_melody_ids", []) + if assigned_ids: + await _flag_melodies_outdated(assigned_ids, True) + # Delete the .bsm file if it exists if row.get("binary_path"): bsm_path = Path(row["binary_path"]) @@ -184,10 +205,26 @@ async def delete_built_melody(melody_id: str) -> None: await db.delete_built_melody(melody_id) +async def toggle_builtin(melody_id: str) -> BuiltMelodyInDB: + """Toggle the is_builtin flag for an archetype.""" + row = await db.get_built_melody(melody_id) + if not row: + raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found") + new_value = not row.get("is_builtin", False) + await db.update_builtin_flag(melody_id, new_value) + return await get_built_melody(melody_id) + + # ============================================================================ # Build Actions # ============================================================================ +async def _do_build(melody_id: str) -> BuiltMelodyInDB: + """Internal: build both binary and PROGMEM code, return updated record.""" + await build_binary(melody_id) + return await build_builtin_code(melody_id) + + async def build_binary(melody_id: str) -> BuiltMelodyInDB: """Parse steps and write a .bsm binary file to storage.""" row = await db.get_built_melody(melody_id) @@ -236,6 +273,48 @@ async def get_binary_path(melody_id: str) -> Optional[Path]: return path +async def generate_builtin_list() -> str: + """Generate a C++ header with PROGMEM arrays for all is_builtin archetypes.""" + rows = await db.list_built_melodies() + builtin_rows = [r for r in rows if r.get("is_builtin")] + + if not builtin_rows: + return "// No built-in archetypes defined.\n" + + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + parts = [ + f"// Auto-generated Built-in Archetype List", + f"// Generated: {timestamp}", + f"// Total built-ins: {len(builtin_rows)}", + "", + "#pragma once", + "#include ", + "", + ] + + entry_refs = [] + for row in builtin_rows: + values = steps_string_to_values(row["steps"]) + array_name = f"melody_builtin_{row['name'].lower().replace(' ', '_')}" + display_name = row["name"].replace("_", " ").title() + pid = row.get("pid") or f"builtin_{row['name'].lower()}" + + parts.append(f"// {display_name} | PID: {pid} | Steps: {len(values)}") + parts.append(format_melody_array(row["name"].lower().replace(" ", "_"), values)) + parts.append("") + entry_refs.append((display_name, pid, array_name, len(values))) + + # Generate MELODY_LIBRARY array + parts.append("// --- MELODY_LIBRARY entries ---") + parts.append("// Add these to your firmware's MELODY_LIBRARY[] array:") + parts.append("// {") + for display_name, pid, array_name, step_count in entry_refs: + parts.append(f'// {{ "{display_name}", "{pid}", {array_name}, {step_count} }},') + parts.append("// };") + + return "\n".join(parts) + + # ============================================================================ # Assignment # ============================================================================ @@ -251,6 +330,9 @@ async def assign_to_melody(built_id: str, firestore_melody_id: str) -> BuiltMelo assigned.append(firestore_melody_id) await db.update_assigned_melody_ids(built_id, assigned) + # Clear outdated flag on the melody being assigned + await _flag_melodies_outdated([firestore_melody_id], False) + return await get_built_melody(built_id) @@ -262,6 +344,10 @@ async def unassign_from_melody(built_id: str, firestore_melody_id: str) -> Built assigned = [mid for mid in row.get("assigned_melody_ids", []) if mid != firestore_melody_id] await db.update_assigned_melody_ids(built_id, assigned) + + # Flag the melody as outdated since it no longer has an archetype + await _flag_melodies_outdated([firestore_melody_id], True) + return await get_built_melody(built_id) @@ -272,3 +358,48 @@ async def get_built_melody_for_firestore_id(firestore_melody_id: str) -> Optiona if firestore_melody_id in row.get("assigned_melody_ids", []): return _row_to_built_melody(row) return None + + +# ============================================================================ +# Outdated Flag Helpers +# ============================================================================ + +async def _flag_melodies_outdated(melody_ids: List[str], outdated: bool) -> None: + """Set or clear the outdated_archetype flag on a list of Firestore melody IDs. + + This updates both SQLite (melody_drafts) and Firestore (published melodies). + We import inline to avoid circular imports. + """ + if not melody_ids: + return + + try: + from melodies import database as melody_db + from shared.firebase import get_db as get_firestore + except ImportError: + logger.warning("Could not import melody/firebase modules — skipping outdated flag update") + return + + firestore_db = get_firestore() + + for melody_id in melody_ids: + try: + row = await melody_db.get_melody(melody_id) + if not row: + continue + + data = row["data"] + info = dict(data.get("information", {})) + info["outdated_archetype"] = outdated + data["information"] = info + + await melody_db.update_melody(melody_id, data) + + # If published, also update Firestore + if row.get("status") == "published": + doc_ref = firestore_db.collection("melodies").document(melody_id) + doc_ref.update({"information.outdated_archetype": outdated}) + + logger.info(f"Set outdated_archetype={outdated} on melody {melody_id}") + except Exception as e: + logger.error(f"Failed to set outdated flag on melody {melody_id}: {e}") diff --git a/backend/database/core.py b/backend/database/core.py index 9583561..6bc7c28 100644 --- a/backend/database/core.py +++ b/backend/database/core.py @@ -207,6 +207,7 @@ async def init_db(): "ALTER TABLE crm_media ADD COLUMN thumbnail_path TEXT", "ALTER TABLE crm_quotation_items ADD COLUMN description_en TEXT", "ALTER TABLE crm_quotation_items ADD COLUMN description_gr TEXT", + "ALTER TABLE built_melodies ADD COLUMN is_builtin INTEGER NOT NULL DEFAULT 0", ] for m in _migrations: try: diff --git a/backend/devices/models.py b/backend/devices/models.py index 8ded275..9c3acec 100644 --- a/backend/devices/models.py +++ b/backend/devices/models.py @@ -126,6 +126,12 @@ class DeviceCreate(BaseModel): websocket_url: str = "" churchAssistantURL: str = "" staffNotes: str = "" + hw_family: str = "" + hw_revision: str = "" + tags: List[str] = [] + serial_number: str = "" + customer_id: str = "" + mfg_status: str = "" class DeviceUpdate(BaseModel): @@ -145,10 +151,16 @@ class DeviceUpdate(BaseModel): websocket_url: Optional[str] = None churchAssistantURL: Optional[str] = None staffNotes: Optional[str] = None + hw_family: Optional[str] = None + hw_revision: Optional[str] = None + tags: Optional[List[str]] = None + customer_id: Optional[str] = None + mfg_status: Optional[str] = None class DeviceInDB(DeviceCreate): id: str + # Legacy field — kept for backwards compat; new docs use serial_number device_id: str = "" @@ -157,6 +169,15 @@ class DeviceListResponse(BaseModel): total: int +class DeviceNoteCreate(BaseModel): + content: str + created_by: str = "" + + +class DeviceNoteUpdate(BaseModel): + content: str + + class DeviceUserInfo(BaseModel): """User info resolved from device_users sub-collection or user_list.""" user_id: str = "" diff --git a/backend/devices/router.py b/backend/devices/router.py index d2129ac..040b6a7 100644 --- a/backend/devices/router.py +++ b/backend/devices/router.py @@ -1,17 +1,25 @@ -from fastapi import APIRouter, Depends, Query -from typing import Optional +import uuid +from datetime import datetime +from fastapi import APIRouter, Depends, Query, HTTPException +from typing import Optional, List +from pydantic import BaseModel from auth.models import TokenPayload from auth.dependencies import require_permission from devices.models import ( DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse, DeviceUsersResponse, DeviceUserInfo, + DeviceNoteCreate, DeviceNoteUpdate, ) from devices import service import database as mqtt_db from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse +from shared.firebase import get_db as get_firestore router = APIRouter(prefix="/api/devices", tags=["devices"]) +NOTES_COLLECTION = "notes" +CRM_COLLECTION = "crm_customers" + @router.get("", response_model=DeviceListResponse) async def list_devices( @@ -79,3 +87,375 @@ async def get_device_alerts( """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]) + + +# ───────────────────────────────────────────────────────────────────────────── +# Device Notes +# ───────────────────────────────────────────────────────────────────────────── + +@router.get("/{device_id}/notes") +async def list_device_notes( + device_id: str, + _user: TokenPayload = Depends(require_permission("devices", "view")), +): + """List all notes for a device.""" + db = get_firestore() + docs = db.collection(NOTES_COLLECTION).where("device_id", "==", device_id).order_by("created_at").stream() + notes = [] + for doc in docs: + note = doc.to_dict() + note["id"] = doc.id + # Convert Firestore Timestamps to ISO strings + for f in ("created_at", "updated_at"): + if hasattr(note.get(f), "isoformat"): + note[f] = note[f].isoformat() + notes.append(note) + return {"notes": notes, "total": len(notes)} + + +@router.post("/{device_id}/notes", status_code=201) +async def create_device_note( + device_id: str, + body: DeviceNoteCreate, + _user: TokenPayload = Depends(require_permission("devices", "edit")), +): + """Create a new note for a device.""" + db = get_firestore() + now = datetime.utcnow() + note_id = str(uuid.uuid4()) + note_data = { + "device_id": device_id, + "content": body.content, + "created_by": body.created_by or _user.name or "", + "created_at": now, + "updated_at": now, + } + db.collection(NOTES_COLLECTION).document(note_id).set(note_data) + note_data["id"] = note_id + note_data["created_at"] = now.isoformat() + note_data["updated_at"] = now.isoformat() + return note_data + + +@router.put("/{device_id}/notes/{note_id}") +async def update_device_note( + device_id: str, + note_id: str, + body: DeviceNoteUpdate, + _user: TokenPayload = Depends(require_permission("devices", "edit")), +): + """Update an existing device note.""" + db = get_firestore() + doc_ref = db.collection(NOTES_COLLECTION).document(note_id) + doc = doc_ref.get() + if not doc.exists or doc.to_dict().get("device_id") != device_id: + raise HTTPException(status_code=404, detail="Note not found") + now = datetime.utcnow() + doc_ref.update({"content": body.content, "updated_at": now}) + updated = doc.to_dict() + updated["id"] = note_id + updated["content"] = body.content + updated["updated_at"] = now.isoformat() + if hasattr(updated.get("created_at"), "isoformat"): + updated["created_at"] = updated["created_at"].isoformat() + return updated + + +@router.delete("/{device_id}/notes/{note_id}", status_code=204) +async def delete_device_note( + device_id: str, + note_id: str, + _user: TokenPayload = Depends(require_permission("devices", "edit")), +): + """Delete a device note.""" + db = get_firestore() + doc_ref = db.collection(NOTES_COLLECTION).document(note_id) + doc = doc_ref.get() + if not doc.exists or doc.to_dict().get("device_id") != device_id: + raise HTTPException(status_code=404, detail="Note not found") + doc_ref.delete() + + +# ───────────────────────────────────────────────────────────────────────────── +# Device Tags +# ───────────────────────────────────────────────────────────────────────────── + +class TagsUpdate(BaseModel): + tags: List[str] + + +@router.put("/{device_id}/tags", response_model=DeviceInDB) +async def update_device_tags( + device_id: str, + body: TagsUpdate, + _user: TokenPayload = Depends(require_permission("devices", "edit")), +): + """Replace the tags list for a device.""" + return service.update_device(device_id, DeviceUpdate(tags=body.tags)) + + +# ───────────────────────────────────────────────────────────────────────────── +# Assign Device to Customer +# ───────────────────────────────────────────────────────────────────────────── + +class CustomerSearchResult(BaseModel): + id: str + name: str + email: str + organization: str = "" + + +class AssignCustomerBody(BaseModel): + customer_id: str + label: str = "" + + +@router.get("/{device_id}/customer-search") +async def search_customers_for_device( + device_id: str, + q: str = Query(""), + _user: TokenPayload = Depends(require_permission("devices", "view")), +): + """Search customers by name, email, phone, org, or tags, returning top 20 matches.""" + db = get_firestore() + docs = db.collection(CRM_COLLECTION).stream() + results = [] + q_lower = q.lower().strip() + for doc in docs: + data = doc.to_dict() + name = data.get("name", "") or "" + surname = data.get("surname", "") or "" + email = data.get("email", "") or "" + organization = data.get("organization", "") or "" + phone = data.get("phone", "") or "" + tags = " ".join(data.get("tags", []) or []) + location = data.get("location") or {} + city = location.get("city", "") or "" + searchable = f"{name} {surname} {email} {organization} {phone} {tags} {city}".lower() + if not q_lower or q_lower in searchable: + results.append({ + "id": doc.id, + "name": name, + "surname": surname, + "email": email, + "organization": organization, + "city": city, + }) + if len(results) >= 20: + break + return {"results": results} + + +@router.post("/{device_id}/assign-customer") +async def assign_device_to_customer( + device_id: str, + body: AssignCustomerBody, + _user: TokenPayload = Depends(require_permission("devices", "edit")), +): + """Assign a device to a customer. + + - Sets owner field on the device document. + - Adds a console_device entry to the customer's owned_items list. + """ + db = get_firestore() + + # Verify device exists + device = service.get_device(device_id) + + # Get customer + customer_ref = db.collection(CRM_COLLECTION).document(body.customer_id) + customer_doc = customer_ref.get() + if not customer_doc.exists: + raise HTTPException(status_code=404, detail="Customer not found") + customer_data = customer_doc.to_dict() + customer_email = customer_data.get("email", "") + + # Update device: owner email + customer_id + device_ref = db.collection("devices").document(device_id) + device_ref.update({"owner": customer_email, "customer_id": body.customer_id}) + + # Add to customer owned_items (avoid duplicates) + owned_items = customer_data.get("owned_items", []) or [] + already_assigned = any( + item.get("type") == "console_device" and item.get("console_device", {}).get("device_id") == device_id + for item in owned_items + ) + if not already_assigned: + owned_items.append({ + "type": "console_device", + "console_device": { + "device_id": device_id, + "label": body.label or device.device_name or device_id, + } + }) + customer_ref.update({"owned_items": owned_items}) + + return {"status": "assigned", "device_id": device_id, "customer_id": body.customer_id} + + +@router.delete("/{device_id}/assign-customer", status_code=204) +async def unassign_device_from_customer( + device_id: str, + customer_id: str = Query(...), + _user: TokenPayload = Depends(require_permission("devices", "edit")), +): + """Remove device assignment from a customer.""" + db = get_firestore() + + # Clear customer_id on device + device_ref = db.collection("devices").document(device_id) + device_ref.update({"customer_id": ""}) + + # Remove from customer owned_items + customer_ref = db.collection(CRM_COLLECTION).document(customer_id) + customer_doc = customer_ref.get() + if customer_doc.exists: + customer_data = customer_doc.to_dict() + owned_items = [ + item for item in (customer_data.get("owned_items") or []) + if not (item.get("type") == "console_device" and item.get("console_device", {}).get("device_id") == device_id) + ] + customer_ref.update({"owned_items": owned_items}) + + +# ───────────────────────────────────────────────────────────────────────────── +# Customer detail (for Owner display in fleet) +# ───────────────────────────────────────────────────────────────────────────── + +@router.get("/{device_id}/customer") +async def get_device_customer( + device_id: str, + _user: TokenPayload = Depends(require_permission("devices", "view")), +): + """Return basic customer details for a device's assigned customer_id.""" + db = get_firestore() + device_ref = db.collection("devices").document(device_id) + device_doc = device_ref.get() + if not device_doc.exists: + raise HTTPException(status_code=404, detail="Device not found") + device_data = device_doc.to_dict() or {} + customer_id = device_data.get("customer_id") + if not customer_id: + return {"customer": None} + customer_doc = db.collection(CRM_COLLECTION).document(customer_id).get() + if not customer_doc.exists: + return {"customer": None} + cd = customer_doc.to_dict() or {} + return { + "customer": { + "id": customer_doc.id, + "name": cd.get("name") or "", + "email": cd.get("email") or "", + "organization": cd.get("organization") or "", + "phone": cd.get("phone") or "", + } + } + + +# ───────────────────────────────────────────────────────────────────────────── +# User list management (for Manage tab — assign/remove users from user_list) +# ───────────────────────────────────────────────────────────────────────────── + +class UserSearchResult(BaseModel): + id: str + display_name: str = "" + email: str = "" + phone: str = "" + photo_url: str = "" + + +@router.get("/{device_id}/user-search") +async def search_users_for_device( + device_id: str, + q: str = Query(""), + _user: TokenPayload = Depends(require_permission("devices", "view")), +): + """Search the users collection by name, email, or phone.""" + db = get_firestore() + docs = db.collection("users").stream() + results = [] + q_lower = q.lower().strip() + for doc in docs: + data = doc.to_dict() or {} + name = (data.get("display_name") or "").lower() + email = (data.get("email") or "").lower() + phone = (data.get("phone") or "").lower() + if not q_lower or q_lower in name or q_lower in email or q_lower in phone: + results.append({ + "id": doc.id, + "display_name": data.get("display_name") or "", + "email": data.get("email") or "", + "phone": data.get("phone") or "", + "photo_url": data.get("photo_url") or "", + }) + if len(results) >= 20: + break + return {"results": results} + + +class AddUserBody(BaseModel): + user_id: str + + +@router.post("/{device_id}/user-list", status_code=200) +async def add_user_to_device( + device_id: str, + body: AddUserBody, + _user: TokenPayload = Depends(require_permission("devices", "edit")), +): + """Add a user reference to the device's user_list field.""" + db = get_firestore() + device_ref = db.collection("devices").document(device_id) + device_doc = device_ref.get() + if not device_doc.exists: + raise HTTPException(status_code=404, detail="Device not found") + + # Verify user exists + user_doc = db.collection("users").document(body.user_id).get() + if not user_doc.exists: + raise HTTPException(status_code=404, detail="User not found") + + data = device_doc.to_dict() or {} + user_list = data.get("user_list", []) or [] + + # Avoid duplicates — check both string paths and DocumentReferences + from google.cloud.firestore_v1 import DocumentReference as DocRef + existing_ids = set() + for entry in user_list: + if isinstance(entry, DocRef): + existing_ids.add(entry.id) + elif isinstance(entry, str): + existing_ids.add(entry.split("/")[-1]) + + if body.user_id not in existing_ids: + user_ref = db.collection("users").document(body.user_id) + user_list.append(user_ref) + device_ref.update({"user_list": user_list}) + + return {"status": "added", "user_id": body.user_id} + + +@router.delete("/{device_id}/user-list/{user_id}", status_code=200) +async def remove_user_from_device( + device_id: str, + user_id: str, + _user: TokenPayload = Depends(require_permission("devices", "edit")), +): + """Remove a user reference from the device's user_list field.""" + db = get_firestore() + device_ref = db.collection("devices").document(device_id) + device_doc = device_ref.get() + if not device_doc.exists: + raise HTTPException(status_code=404, detail="Device not found") + + data = device_doc.to_dict() or {} + user_list = data.get("user_list", []) or [] + + # Remove any entry that resolves to this user_id + new_list = [ + entry for entry in user_list + if not (isinstance(entry, str) and entry.split("/")[-1] == user_id) + ] + device_ref.update({"user_list": new_list}) + + return {"status": "removed", "user_id": user_id} diff --git a/backend/devices/service.py b/backend/devices/service.py index f091600..ff514d9 100644 --- a/backend/devices/service.py +++ b/backend/devices/service.py @@ -52,10 +52,11 @@ def _generate_serial_number() -> str: def _ensure_unique_serial(db) -> str: """Generate a serial number and verify it doesn't already exist in Firestore.""" existing_sns = set() - for doc in db.collection(COLLECTION).select(["device_id"]).stream(): + for doc in db.collection(COLLECTION).select(["serial_number"]).stream(): data = doc.to_dict() - if data.get("device_id"): - existing_sns.add(data["device_id"]) + sn = data.get("serial_number") or data.get("device_id") + if sn: + existing_sns.add(sn) for _ in range(100): # safety limit sn = _generate_serial_number() @@ -95,18 +96,40 @@ def _sanitize_dict(d: dict) -> dict: return result +def _auto_upgrade_claimed(doc_ref, data: dict) -> dict: + """If the device has entries in user_list and isn't already claimed/decommissioned, + upgrade mfg_status to 'claimed' automatically and return the updated data dict.""" + current_status = data.get("mfg_status", "") + if current_status in ("claimed", "decommissioned"): + return data + user_list = data.get("user_list", []) or [] + if user_list: + doc_ref.update({"mfg_status": "claimed"}) + data = dict(data) + data["mfg_status"] = "claimed" + return data + + def _doc_to_device(doc) -> DeviceInDB: - """Convert a Firestore document snapshot to a DeviceInDB model.""" - data = _sanitize_dict(doc.to_dict()) + """Convert a Firestore document snapshot to a DeviceInDB model. + + Also auto-upgrades mfg_status to 'claimed' if user_list is non-empty. + """ + raw = doc.to_dict() + raw = _auto_upgrade_claimed(doc.reference, raw) + data = _sanitize_dict(raw) return DeviceInDB(id=doc.id, **data) +FLEET_STATUSES = {"sold", "claimed"} + + def list_devices( search: str | None = None, online_only: bool | None = None, subscription_tier: str | None = None, ) -> list[DeviceInDB]: - """List devices with optional filters.""" + """List fleet devices (sold + claimed only) with optional filters.""" db = get_db() ref = db.collection(COLLECTION) query = ref @@ -118,6 +141,14 @@ def list_devices( results = [] for doc in docs: + raw = doc.to_dict() or {} + + # Only include sold/claimed devices in the fleet view. + # Legacy devices without mfg_status are included to avoid breaking old data. + mfg_status = raw.get("mfg_status") + if mfg_status and mfg_status not in FLEET_STATUSES: + continue + device = _doc_to_device(doc) # Client-side filters @@ -128,7 +159,7 @@ def list_devices( search_lower = search.lower() name_match = search_lower in (device.device_name or "").lower() location_match = search_lower in (device.device_location or "").lower() - sn_match = search_lower in (device.device_id or "").lower() + sn_match = search_lower in (device.serial_number or "").lower() if not (name_match or location_match or sn_match): continue diff --git a/backend/equipment/service.py b/backend/equipment/service.py index eb09ffa..eee4608 100644 --- a/backend/equipment/service.py +++ b/backend/equipment/service.py @@ -4,7 +4,7 @@ from shared.firebase import get_db from shared.exceptions import NotFoundError from equipment.models import NoteCreate, NoteUpdate, NoteInDB -COLLECTION = "equipment_notes" +COLLECTION = "notes" VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "action_item", "other"} diff --git a/backend/firmware/models.py b/backend/firmware/models.py index 17a41d6..80cf6b1 100644 --- a/backend/firmware/models.py +++ b/backend/firmware/models.py @@ -11,7 +11,7 @@ class UpdateType(str, Enum): class FirmwareVersion(BaseModel): id: str - hw_type: str # e.g. "vesper", "vesper_plus", "vesper_pro" + hw_type: str # e.g. "vesper", "vesper_plus", "vesper_pro", "bespoke" channel: str # "stable", "beta", "alpha", "testing" version: str # semver e.g. "1.5" filename: str @@ -20,8 +20,10 @@ class FirmwareVersion(BaseModel): 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 + changelog: Optional[str] = None + release_note: Optional[str] = None is_latest: bool = False + bespoke_uid: Optional[str] = None # only set when hw_type == "bespoke" class FirmwareListResponse(BaseModel): @@ -57,7 +59,7 @@ class FirmwareMetadataResponse(BaseModel): min_fw_version: Optional[str] = None download_url: str uploaded_at: str - notes: Optional[str] = None + release_note: Optional[str] = None # Keep backwards-compatible alias diff --git a/backend/firmware/router.py b/backend/firmware/router.py index 9cde742..6ba2d50 100644 --- a/backend/firmware/router.py +++ b/backend/firmware/router.py @@ -1,5 +1,5 @@ -from fastapi import APIRouter, Depends, Query, UploadFile, File, Form -from fastapi.responses import FileResponse +from fastapi import APIRouter, Depends, Query, UploadFile, File, Form, HTTPException +from fastapi.responses import FileResponse, PlainTextResponse from pydantic import BaseModel from typing import Optional import logging @@ -22,7 +22,9 @@ async def upload_firmware( version: str = Form(...), update_type: UpdateType = Form(UpdateType.mandatory), min_fw_version: Optional[str] = Form(None), - notes: Optional[str] = Form(None), + changelog: Optional[str] = Form(None), + release_note: Optional[str] = Form(None), + bespoke_uid: Optional[str] = Form(None), file: UploadFile = File(...), _user: TokenPayload = Depends(require_permission("manufacturing", "add")), ): @@ -34,7 +36,9 @@ async def upload_firmware( file_bytes=file_bytes, update_type=update_type, min_fw_version=min_fw_version, - notes=notes, + changelog=changelog, + release_note=release_note, + bespoke_uid=bespoke_uid, ) @@ -61,6 +65,18 @@ def get_latest_firmware( return service.get_latest(hw_type, channel, hw_version=hw_version, current_version=current_version) +@router.get("/{hw_type}/{channel}/latest/changelog", response_class=PlainTextResponse) +def get_latest_changelog(hw_type: str, channel: str): + """Returns the full changelog for the latest firmware. Plain text.""" + return service.get_latest_changelog(hw_type, channel) + + +@router.get("/{hw_type}/{channel}/{version}/info/changelog", response_class=PlainTextResponse) +def get_version_changelog(hw_type: str, channel: str, version: str): + """Returns the full changelog for a specific firmware version. Plain text.""" + return service.get_version_changelog(hw_type, channel, version) + + @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. @@ -80,6 +96,33 @@ def download_firmware(hw_type: str, channel: str, version: str): ) +@router.put("/{firmware_id}", response_model=FirmwareVersion) +async def edit_firmware( + firmware_id: str, + channel: Optional[str] = Form(None), + version: Optional[str] = Form(None), + update_type: Optional[UpdateType] = Form(None), + min_fw_version: Optional[str] = Form(None), + changelog: Optional[str] = Form(None), + release_note: Optional[str] = Form(None), + bespoke_uid: Optional[str] = Form(None), + file: Optional[UploadFile] = File(None), + _user: TokenPayload = Depends(require_permission("manufacturing", "add")), +): + file_bytes = await file.read() if file and file.filename else None + return service.edit_firmware( + doc_id=firmware_id, + channel=channel, + version=version, + update_type=update_type, + min_fw_version=min_fw_version, + changelog=changelog, + release_note=release_note, + bespoke_uid=bespoke_uid, + file_bytes=file_bytes, + ) + + @router.delete("/{firmware_id}", status_code=204) def delete_firmware( firmware_id: str, diff --git a/backend/firmware/service.py b/backend/firmware/service.py index aff62c9..175ca19 100644 --- a/backend/firmware/service.py +++ b/backend/firmware/service.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) COLLECTION = "firmware_versions" -VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini"} +VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini", "bespoke"} VALID_CHANNELS = {"stable", "beta", "alpha", "testing"} @@ -43,8 +43,10 @@ def _doc_to_firmware_version(doc) -> FirmwareVersion: update_type=data.get("update_type", UpdateType.mandatory), min_fw_version=data.get("min_fw_version"), uploaded_at=uploaded_str, - notes=data.get("notes"), + changelog=data.get("changelog"), + release_note=data.get("release_note"), is_latest=data.get("is_latest", False), + bespoke_uid=data.get("bespoke_uid"), ) @@ -65,7 +67,7 @@ def _fw_to_metadata_response(fw: FirmwareVersion) -> FirmwareMetadataResponse: min_fw_version=fw.min_fw_version, download_url=download_url, uploaded_at=fw.uploaded_at, - notes=fw.notes, + release_note=fw.release_note, ) @@ -76,33 +78,59 @@ def upload_firmware( file_bytes: bytes, update_type: UpdateType = UpdateType.mandatory, min_fw_version: str | None = None, - notes: str | None = None, + changelog: str | None = None, + release_note: str | None = None, + bespoke_uid: 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(sorted(VALID_HW_TYPES))}") if channel not in VALID_CHANNELS: raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(sorted(VALID_CHANNELS))}") + if hw_type == "bespoke" and not bespoke_uid: + raise HTTPException(status_code=400, detail="bespoke_uid is required when hw_type is 'bespoke'") + + db = get_db() + sha256 = hashlib.sha256(file_bytes).hexdigest() + now = datetime.now(timezone.utc) + + # For bespoke firmware: if a firmware with the same bespoke_uid already exists, + # overwrite it (delete old doc + file, reuse same storage path keyed by uid). + if hw_type == "bespoke" and bespoke_uid: + existing_docs = list( + db.collection(COLLECTION) + .where("hw_type", "==", "bespoke") + .where("bespoke_uid", "==", bespoke_uid) + .stream() + ) + for old_doc in existing_docs: + old_data = old_doc.to_dict() or {} + old_path = _storage_path("bespoke", old_data.get("channel", channel), old_data.get("version", version)) + if old_path.exists(): + old_path.unlink() + try: + old_path.parent.rmdir() + except OSError: + pass + old_doc.reference.delete() dest = _storage_path(hw_type, channel, version) dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(file_bytes) - sha256 = hashlib.sha256(file_bytes).hexdigest() - now = datetime.now(timezone.utc) doc_id = str(uuid.uuid4()) - db = get_db() - # Mark previous latest for this hw_type+channel as no longer latest - prev_docs = ( - db.collection(COLLECTION) - .where("hw_type", "==", hw_type) - .where("channel", "==", channel) - .where("is_latest", "==", True) - .stream() - ) - for prev in prev_docs: - prev.reference.update({"is_latest": False}) + # (skip for bespoke — each bespoke_uid is its own independent firmware) + if hw_type != "bespoke": + prev_docs = ( + db.collection(COLLECTION) + .where("hw_type", "==", hw_type) + .where("channel", "==", channel) + .where("is_latest", "==", True) + .stream() + ) + for prev in prev_docs: + prev.reference.update({"is_latest": False}) doc_ref = db.collection(COLLECTION).document(doc_id) doc_ref.set({ @@ -115,8 +143,10 @@ def upload_firmware( "update_type": update_type.value, "min_fw_version": min_fw_version, "uploaded_at": now, - "notes": notes, + "changelog": changelog, + "release_note": release_note, "is_latest": True, + "bespoke_uid": bespoke_uid, }) return _doc_to_firmware_version(doc_ref.get()) @@ -142,6 +172,8 @@ def list_firmware( def get_latest(hw_type: str, channel: str, hw_version: str | None = None, current_version: str | None = None) -> FirmwareMetadataResponse: if hw_type not in VALID_HW_TYPES: raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'") + if hw_type == "bespoke": + raise HTTPException(status_code=400, detail="Bespoke firmware is not served via auto-update. Use the direct download URL.") if channel not in VALID_CHANNELS: raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'") @@ -182,6 +214,52 @@ def get_version_info(hw_type: str, channel: str, version: str) -> FirmwareMetada return _fw_to_metadata_response(_doc_to_firmware_version(docs[0])) +def get_latest_changelog(hw_type: str, channel: str) -> str: + 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("is_latest", "==", True) + .limit(1) + .stream() + ) + if not docs: + raise NotFoundError("Firmware") + fw = _doc_to_firmware_version(docs[0]) + if not fw.changelog: + raise NotFoundError("Changelog") + return fw.changelog + + +def get_version_changelog(hw_type: str, channel: str, version: str) -> str: + 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") + fw = _doc_to_firmware_version(docs[0]) + if not fw.changelog: + raise NotFoundError("Changelog") + return fw.changelog + + def get_firmware_path(hw_type: str, channel: str, version: str) -> Path: path = _storage_path(hw_type, channel, version) if not path.exists(): @@ -205,6 +283,82 @@ def record_ota_event(event_type: str, payload: dict[str, Any]) -> None: logger.warning("Failed to persist OTA event (%s): %s", event_type, exc) +def edit_firmware( + doc_id: str, + channel: str | None = None, + version: str | None = None, + update_type: UpdateType | None = None, + min_fw_version: str | None = None, + changelog: str | None = None, + release_note: str | None = None, + bespoke_uid: str | None = None, + file_bytes: bytes | None = None, +) -> FirmwareVersion: + db = get_db() + doc_ref = db.collection(COLLECTION).document(doc_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Firmware") + + data = doc.to_dict() or {} + hw_type = data["hw_type"] + old_channel = data.get("channel", "") + old_version = data.get("version", "") + + effective_channel = channel if channel is not None else old_channel + effective_version = version if version is not None else old_version + + if channel is not None and channel not in VALID_CHANNELS: + raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(sorted(VALID_CHANNELS))}") + + updates: dict = {} + if channel is not None: + updates["channel"] = channel + if version is not None: + updates["version"] = version + if update_type is not None: + updates["update_type"] = update_type.value + if min_fw_version is not None: + updates["min_fw_version"] = min_fw_version if min_fw_version else None + if changelog is not None: + updates["changelog"] = changelog if changelog else None + if release_note is not None: + updates["release_note"] = release_note if release_note else None + if bespoke_uid is not None: + updates["bespoke_uid"] = bespoke_uid if bespoke_uid else None + + if file_bytes is not None: + # Move binary if path changed + old_path = _storage_path(hw_type, old_channel, old_version) + new_path = _storage_path(hw_type, effective_channel, effective_version) + if old_path != new_path and old_path.exists(): + old_path.unlink() + try: + old_path.parent.rmdir() + except OSError: + pass + new_path.parent.mkdir(parents=True, exist_ok=True) + new_path.write_bytes(file_bytes) + updates["sha256"] = hashlib.sha256(file_bytes).hexdigest() + updates["size_bytes"] = len(file_bytes) + elif (channel is not None and channel != old_channel) or (version is not None and version != old_version): + # Path changed but no new file — move existing binary + old_path = _storage_path(hw_type, old_channel, old_version) + new_path = _storage_path(hw_type, effective_channel, effective_version) + if old_path.exists() and old_path != new_path: + new_path.parent.mkdir(parents=True, exist_ok=True) + old_path.rename(new_path) + try: + old_path.parent.rmdir() + except OSError: + pass + + if updates: + doc_ref.update(updates) + + return _doc_to_firmware_version(doc_ref.get()) + + def delete_firmware(doc_id: str) -> None: db = get_db() doc_ref = db.collection(COLLECTION).document(doc_id) diff --git a/backend/main.py b/backend/main.py index 764a7a3..b62f64a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -24,6 +24,7 @@ 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 public.router import router as public_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 @@ -67,6 +68,7 @@ app.include_router(crm_comms_router) app.include_router(crm_media_router) app.include_router(crm_nextcloud_router) app.include_router(crm_quotations_router) +app.include_router(public_router) async def nextcloud_keepalive_loop(): diff --git a/backend/manufacturing/models.py b/backend/manufacturing/models.py index 491876e..f9ed93c 100644 --- a/backend/manufacturing/models.py +++ b/backend/manufacturing/models.py @@ -55,6 +55,13 @@ class MfgStatus(str, Enum): decommissioned = "decommissioned" +class LifecycleEntry(BaseModel): + status_id: str + date: str # ISO 8601 UTC string + note: Optional[str] = None + set_by: Optional[str] = None + + class BatchCreate(BaseModel): board_type: BoardType board_version: str = Field( @@ -84,6 +91,9 @@ class DeviceInventoryItem(BaseModel): owner: Optional[str] = None assigned_to: Optional[str] = None device_name: Optional[str] = None + lifecycle_history: Optional[List["LifecycleEntry"]] = None + customer_id: Optional[str] = None + user_list: Optional[List[str]] = None class DeviceInventoryListResponse(BaseModel): @@ -94,11 +104,19 @@ class DeviceInventoryListResponse(BaseModel): class DeviceStatusUpdate(BaseModel): status: MfgStatus note: Optional[str] = None + force_claimed: bool = False class DeviceAssign(BaseModel): - customer_email: str - customer_name: Optional[str] = None + customer_id: str + + +class CustomerSearchResult(BaseModel): + id: str + name: str = "" + email: str = "" + organization: str = "" + phone: str = "" class RecentActivityItem(BaseModel): diff --git a/backend/manufacturing/router.py b/backend/manufacturing/router.py index f5ee1c1..caa69ae 100644 --- a/backend/manufacturing/router.py +++ b/backend/manufacturing/router.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File, Form +from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File from fastapi.responses import Response from fastapi.responses import RedirectResponse from typing import Optional +from pydantic import BaseModel from auth.models import TokenPayload from auth.dependencies import require_permission @@ -14,9 +15,23 @@ from manufacturing.models import ( from manufacturing import service from manufacturing import audit from shared.exceptions import NotFoundError +from shared.firebase import get_db as get_firestore + + +class LifecycleEntryPatch(BaseModel): + index: int + date: Optional[str] = None + note: Optional[str] = None + +class LifecycleEntryCreate(BaseModel): + status_id: str + date: Optional[str] = None + note: Optional[str] = None VALID_FLASH_ASSETS = {"bootloader.bin", "partitions.bin"} VALID_HW_TYPES_MFG = {"vesper", "vesper_plus", "vesper_pro", "agnus", "agnus_mini", "chronos", "chronos_pro"} +# Bespoke UIDs are dynamic — we allow any non-empty slug that doesn't clash with +# a standard hw_type name. The flash-asset upload endpoint checks this below. router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"]) @@ -83,13 +98,75 @@ def get_device( return service.get_device_by_sn(sn) +@router.get("/customers/search") +def search_customers( + q: str = Query(""), + _user: TokenPayload = Depends(require_permission("manufacturing", "view")), +): + """Search CRM customers by name, email, phone, organization, or tags.""" + results = service.search_customers(q) + return {"results": results} + + +@router.get("/customers/{customer_id}") +def get_customer( + customer_id: str, + _user: TokenPayload = Depends(require_permission("manufacturing", "view")), +): + """Get a single CRM customer by ID.""" + db = get_firestore() + doc = 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() or {} + loc = data.get("location") or {} + city = loc.get("city") if isinstance(loc, dict) else None + return { + "id": doc.id, + "name": data.get("name") or "", + "surname": data.get("surname") or "", + "email": data.get("email") or "", + "organization": data.get("organization") or "", + "phone": data.get("phone") or "", + "city": city or "", + } + + @router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem) async def update_status( sn: str, body: DeviceStatusUpdate, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), ): - result = service.update_device_status(sn, body) + # Guard: claimed requires at least one user in user_list + # (allow if explicitly force_claimed=true, which the mfg UI sets after adding a user manually) + if body.status.value == "claimed": + db = get_firestore() + docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) + if docs: + data = docs[0].to_dict() or {} + user_list = data.get("user_list", []) or [] + if not user_list and not getattr(body, "force_claimed", False): + raise HTTPException( + status_code=400, + detail="Cannot set status to 'claimed': device has no users in user_list. " + "Assign a user first, then set to Claimed.", + ) + + # Guard: sold requires a customer assigned + if body.status.value == "sold": + db = get_firestore() + docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) + if docs: + data = docs[0].to_dict() or {} + if not data.get("customer_id"): + raise HTTPException( + status_code=400, + detail="Cannot set status to 'sold' without an assigned customer. " + "Use the 'Assign to Customer' action first.", + ) + + result = service.update_device_status(sn, body, set_by=user.email) await audit.log_action( admin_user=user.email, action="status_updated", @@ -99,12 +176,91 @@ async def update_status( return result +@router.patch("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem) +async def patch_lifecycle_entry( + sn: str, + body: LifecycleEntryPatch, + user: TokenPayload = Depends(require_permission("manufacturing", "edit")), +): + """Edit the date and/or note of a lifecycle history entry by index.""" + db = get_firestore() + docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) + if not docs: + raise HTTPException(status_code=404, detail="Device not found") + doc_ref = docs[0].reference + data = docs[0].to_dict() or {} + history = data.get("lifecycle_history") or [] + if body.index < 0 or body.index >= len(history): + raise HTTPException(status_code=400, detail="Invalid lifecycle entry index") + if body.date is not None: + history[body.index]["date"] = body.date + if body.note is not None: + history[body.index]["note"] = body.note + doc_ref.update({"lifecycle_history": history}) + from manufacturing.service import _doc_to_inventory_item + return _doc_to_inventory_item(doc_ref.get()) + + +@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=201) +async def create_lifecycle_entry( + sn: str, + body: LifecycleEntryCreate, + user: TokenPayload = Depends(require_permission("manufacturing", "edit")), +): + """Create a lifecycle history entry for a step that has no entry yet (on-the-fly).""" + from datetime import datetime, timezone + db = get_firestore() + docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) + if not docs: + raise HTTPException(status_code=404, detail="Device not found") + doc_ref = docs[0].reference + data = docs[0].to_dict() or {} + history = data.get("lifecycle_history") or [] + new_entry = { + "status_id": body.status_id, + "date": body.date or datetime.now(timezone.utc).isoformat(), + "note": body.note, + "set_by": user.email, + } + history.append(new_entry) + doc_ref.update({"lifecycle_history": history}) + from manufacturing.service import _doc_to_inventory_item + return _doc_to_inventory_item(doc_ref.get()) + + +@router.delete("/devices/{sn}/lifecycle/{index}", response_model=DeviceInventoryItem) +async def delete_lifecycle_entry( + sn: str, + index: int, + user: TokenPayload = Depends(require_permission("manufacturing", "edit")), +): + """Delete a lifecycle history entry by index. Cannot delete the entry for the current status.""" + db = get_firestore() + docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) + if not docs: + raise HTTPException(status_code=404, detail="Device not found") + doc_ref = docs[0].reference + data = docs[0].to_dict() or {} + history = data.get("lifecycle_history") or [] + if index < 0 or index >= len(history): + raise HTTPException(status_code=400, detail="Invalid lifecycle entry index") + current_status = data.get("mfg_status", "") + if history[index].get("status_id") == current_status: + raise HTTPException(status_code=400, detail="Cannot delete the entry for the current status. Change the status first.") + history.pop(index) + doc_ref.update({"lifecycle_history": history}) + from manufacturing.service import _doc_to_inventory_item + return _doc_to_inventory_item(doc_ref.get()) + + @router.get("/devices/{sn}/nvs.bin") async def download_nvs( sn: str, + hw_type_override: Optional[str] = Query(None, description="Override hw_type written to NVS (for bespoke firmware)"), + hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"), user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): - binary = service.get_nvs_binary(sn) + binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override) await audit.log_action( admin_user=user.email, action="device_flashed", @@ -123,12 +279,15 @@ async def assign_device( body: DeviceAssign, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), ): - result = service.assign_device(sn, body) + try: + result = service.assign_device(sn, body) + except NotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) await audit.log_action( admin_user=user.email, action="device_assigned", serial_number=sn, - detail={"customer_email": body.customer_email, "customer_name": body.customer_name}, + detail={"customer_id": body.customer_id}, ) return result @@ -201,8 +360,9 @@ async def upload_flash_asset( and .pio/build/{env}/partitions.bin). Upload them once per hw_type after each PlatformIO build that changes the partition layout. """ - if hw_type not in VALID_HW_TYPES_MFG: - raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES_MFG))}") + # hw_type can be a standard board type OR a bespoke UID (any non-empty slug) + if not hw_type or len(hw_type) > 128: + raise HTTPException(status_code=400, detail="Invalid hw_type/bespoke UID.") if asset not in VALID_FLASH_ASSETS: raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}") data = await file.read() @@ -212,34 +372,38 @@ async def upload_flash_asset( @router.get("/devices/{sn}/bootloader.bin") def download_bootloader( sn: str, + hw_type_override: Optional[str] = Query(None, description="Override hw_type for flash asset lookup (for bespoke firmware)"), _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): """Return the bootloader.bin for this device's hw_type (flashed at 0x1000).""" item = service.get_device_by_sn(sn) + hw_type = hw_type_override or item.hw_type try: - data = service.get_flash_asset(item.hw_type, "bootloader.bin") + data = service.get_flash_asset(hw_type, "bootloader.bin") except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) return Response( content=data, media_type="application/octet-stream", - headers={"Content-Disposition": f'attachment; filename="bootloader_{item.hw_type}.bin"'}, + headers={"Content-Disposition": f'attachment; filename="bootloader_{hw_type}.bin"'}, ) @router.get("/devices/{sn}/partitions.bin") def download_partitions( sn: str, + hw_type_override: Optional[str] = Query(None, description="Override hw_type for flash asset lookup (for bespoke firmware)"), _user: TokenPayload = Depends(require_permission("manufacturing", "view")), ): """Return the partitions.bin for this device's hw_type (flashed at 0x8000).""" item = service.get_device_by_sn(sn) + hw_type = hw_type_override or item.hw_type try: - data = service.get_flash_asset(item.hw_type, "partitions.bin") + data = service.get_flash_asset(hw_type, "partitions.bin") except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) return Response( content=data, media_type="application/octet-stream", - headers={"Content-Disposition": f'attachment; filename="partitions_{item.hw_type}.bin"'}, + headers={"Content-Disposition": f'attachment; filename="partitions_{hw_type}.bin"'}, ) diff --git a/backend/manufacturing/service.py b/backend/manufacturing/service.py index a3b1a82..93437c1 100644 --- a/backend/manufacturing/service.py +++ b/backend/manufacturing/service.py @@ -33,6 +33,18 @@ def _get_existing_sns(db) -> set: return existing +def _resolve_user_list(raw_list: list) -> list[str]: + """Convert user_list entries (DocumentReferences or path strings) to plain user ID strings.""" + from google.cloud.firestore_v1 import DocumentReference + result = [] + for entry in raw_list: + if isinstance(entry, DocumentReference): + result.append(entry.id) + elif isinstance(entry, str): + result.append(entry.split("/")[-1]) + return result + + def _doc_to_inventory_item(doc) -> DeviceInventoryItem: data = doc.to_dict() or {} created_raw = data.get("created_at") @@ -52,6 +64,9 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem: owner=data.get("owner"), assigned_to=data.get("assigned_to"), device_name=data.get("device_name") or None, + lifecycle_history=data.get("lifecycle_history") or [], + customer_id=data.get("customer_id"), + user_list=_resolve_user_list(data.get("user_list") or []), ) @@ -80,11 +95,19 @@ def create_batch(data: BatchCreate) -> BatchResponse: "created_at": now, "owner": None, "assigned_to": None, - "users_list": [], + "user_list": [], # Legacy fields left empty so existing device views don't break "device_name": "", "device_location": "", "is_Online": False, + "lifecycle_history": [ + { + "status_id": "manufactured", + "date": now.isoformat(), + "note": None, + "set_by": None, + } + ], }) serial_numbers.append(sn) @@ -135,14 +158,31 @@ def get_device_by_sn(sn: str) -> DeviceInventoryItem: return _doc_to_inventory_item(docs[0]) -def update_device_status(sn: str, data: DeviceStatusUpdate) -> DeviceInventoryItem: +def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None = None) -> DeviceInventoryItem: db = get_db() docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream()) if not docs: raise NotFoundError("Device") doc_ref = docs[0].reference - update = {"mfg_status": data.status.value} + doc_data = docs[0].to_dict() or {} + now = datetime.now(timezone.utc).isoformat() + + history = doc_data.get("lifecycle_history") or [] + + # Append new lifecycle entry + new_entry = { + "status_id": data.status.value, + "date": now, + "note": data.note if data.note else None, + "set_by": set_by, + } + history.append(new_entry) + + update = { + "mfg_status": data.status.value, + "lifecycle_history": history, + } if data.note: update["mfg_status_note"] = data.note doc_ref.update(update) @@ -150,47 +190,114 @@ def update_device_status(sn: str, data: DeviceStatusUpdate) -> DeviceInventoryIt return _doc_to_inventory_item(doc_ref.get()) -def get_nvs_binary(sn: str) -> bytes: +def get_nvs_binary(sn: str, hw_type_override: str | None = None, hw_revision_override: str | None = None) -> bytes: item = get_device_by_sn(sn) return generate_nvs_binary( serial_number=item.serial_number, - hw_type=item.hw_type, - hw_version=item.hw_version, + hw_family=hw_type_override if hw_type_override else item.hw_type, + hw_revision=hw_revision_override if hw_revision_override else item.hw_version, ) def assign_device(sn: str, data: DeviceAssign) -> DeviceInventoryItem: - from utils.email import send_device_assignment_invite + """Assign a device to a customer by customer_id. + - Stores customer_id on the device doc. + - Adds the device to the customer's owned_items list. + - Sets mfg_status to 'sold' unless device is already 'claimed'. + """ db = get_db() + CRM_COLLECTION = "crm_customers" + + # Get device doc docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream()) if not docs: raise NotFoundError("Device") doc_data = docs[0].to_dict() or {} doc_ref = docs[0].reference - doc_ref.update({ - "owner": data.customer_email, - "assigned_to": data.customer_email, - "mfg_status": "sold", + current_status = doc_data.get("mfg_status", "manufactured") + + # Get customer doc + customer_ref = db.collection(CRM_COLLECTION).document(data.customer_id) + customer_doc = customer_ref.get() + if not customer_doc.exists: + raise NotFoundError("Customer") + customer_data = customer_doc.to_dict() or {} + + # Determine new status: don't downgrade claimed → sold + new_status = current_status if current_status == "claimed" else "sold" + + now = datetime.now(timezone.utc).isoformat() + history = doc_data.get("lifecycle_history") or [] + history.append({ + "status_id": new_status, + "date": now, + "note": "Assigned to customer", + "set_by": None, }) - hw_type = doc_data.get("hw_type", "") - device_name = BOARD_TYPE_LABELS.get(hw_type, hw_type or "Device") + doc_ref.update({ + "customer_id": data.customer_id, + "mfg_status": new_status, + "lifecycle_history": history, + }) - try: - send_device_assignment_invite( - customer_email=data.customer_email, - serial_number=sn, - device_name=device_name, - customer_name=data.customer_name, - ) - except Exception as exc: - logger.error("Assignment succeeded but email failed for %s → %s: %s", sn, data.customer_email, exc) + # Add to customer's owned_items (avoid duplicates) + owned_items = customer_data.get("owned_items", []) or [] + device_doc_id = docs[0].id + already_assigned = any( + item.get("type") == "console_device" + and item.get("console_device", {}).get("device_id") == device_doc_id + for item in owned_items + ) + if not already_assigned: + device_name = doc_data.get("device_name") or BOARD_TYPE_LABELS.get(doc_data.get("hw_type", ""), sn) + owned_items.append({ + "type": "console_device", + "console_device": { + "device_id": device_doc_id, + "serial_number": sn, + "label": device_name, + }, + }) + customer_ref.update({"owned_items": owned_items}) return _doc_to_inventory_item(doc_ref.get()) +def search_customers(q: str) -> list: + """Search crm_customers by name, email, phone, organization, or tags.""" + db = get_db() + CRM_COLLECTION = "crm_customers" + docs = db.collection(CRM_COLLECTION).stream() + results = [] + q_lower = q.lower().strip() + for doc in docs: + data = doc.to_dict() or {} + loc = data.get("location") or {} + loc = loc if isinstance(loc, dict) else {} + city = loc.get("city") or "" + searchable = " ".join(filter(None, [ + data.get("name"), data.get("surname"), + data.get("email"), data.get("phone"), data.get("organization"), + loc.get("address"), loc.get("city"), loc.get("postal_code"), + loc.get("region"), loc.get("country"), + " ".join(data.get("tags") or []), + ])).lower() + if not q_lower or q_lower in searchable: + results.append({ + "id": doc.id, + "name": data.get("name") or "", + "surname": data.get("surname") or "", + "email": data.get("email") or "", + "organization": data.get("organization") or "", + "phone": data.get("phone") or "", + "city": city or "", + }) + return results + + def get_stats() -> ManufacturingStats: db = get_db() docs = list(db.collection(COLLECTION).stream()) diff --git a/backend/melodies/models.py b/backend/melodies/models.py index c238c8a..6520e26 100644 --- a/backend/melodies/models.py +++ b/backend/melodies/models.py @@ -30,6 +30,7 @@ class MelodyInfo(BaseModel): isTrueRing: bool = False previewURL: str = "" archetype_csv: Optional[str] = None + outdated_archetype: bool = False class MelodyAttributes(BaseModel): diff --git a/backend/melodies/router.py b/backend/melodies/router.py index b029c1b..5b58fa8 100644 --- a/backend/melodies/router.py +++ b/backend/melodies/router.py @@ -146,6 +146,23 @@ async def get_files( return service.get_storage_files(melody_id, melody.uid) +@router.patch("/{melody_id}/set-outdated", response_model=MelodyInDB) +async def set_outdated( + melody_id: str, + outdated: bool = Query(...), + _user: TokenPayload = Depends(require_permission("melodies", "edit")), +): + """Manually set or clear the outdated_archetype flag on a melody.""" + melody = await service.get_melody(melody_id) + info = melody.information.model_dump() + info["outdated_archetype"] = outdated + return await service.update_melody( + melody_id, + MelodyUpdate(information=MelodyInfo(**info)), + actor_name=_user.name, + ) + + @router.get("/{melody_id}/download/binary") async def download_binary_file( melody_id: str, diff --git a/backend/public/__init__.py b/backend/public/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/public/router.py b/backend/public/router.py new file mode 100644 index 0000000..55d237e --- /dev/null +++ b/backend/public/router.py @@ -0,0 +1,208 @@ +""" +Public (no-auth) endpoints for CloudFlash and feature gate checks. +""" +from fastapi import APIRouter, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel +from typing import List, Optional + +from settings.public_features_service import get_public_features +from firmware.service import list_firmware +from utils.nvs_generator import generate as generate_nvs +from manufacturing.service import get_device_by_sn +from shared.exceptions import NotFoundError + +router = APIRouter(prefix="/api/public", tags=["public"]) + + +# ── Feature gate ────────────────────────────────────────────────────────────── + +class CloudFlashStatus(BaseModel): + enabled: bool + + +@router.get("/cloudflash/status", response_model=CloudFlashStatus) +async def cloudflash_status(): + """Returns whether the CloudFlash public page is currently enabled.""" + settings = get_public_features() + return CloudFlashStatus(enabled=settings.cloudflash_enabled) + + +def _require_cloudflash_enabled(): + """Raises 403 if CloudFlash is disabled.""" + settings = get_public_features() + if not settings.cloudflash_enabled: + raise HTTPException(status_code=403, detail="CloudFlash is currently disabled.") + + +# ── Public firmware list ─────────────────────────────────────────────────────── + +class PublicFirmwareOption(BaseModel): + hw_type: str + hw_type_label: str + channel: str + version: str + download_url: str + + +HW_TYPE_LABELS = { + "vesper": "Vesper", + "vesper_plus": "Vesper Plus", + "vesper_pro": "Vesper Pro", + "agnus": "Agnus", + "agnus_mini": "Agnus Mini", + "chronos": "Chronos", + "chronos_pro": "Chronos Pro", +} + + +@router.get("/cloudflash/firmware", response_model=List[PublicFirmwareOption]) +async def list_public_firmware(): + """ + Returns all available firmware options (is_latest=True, non-bespoke, stable channel only). + No authentication required — used by the public CloudFlash page. + """ + _require_cloudflash_enabled() + + all_fw = list_firmware() + options = [] + for fw in all_fw: + if not fw.is_latest: + continue + if fw.hw_type == "bespoke": + continue + if fw.channel != "stable": + continue + options.append(PublicFirmwareOption( + hw_type=fw.hw_type, + hw_type_label=HW_TYPE_LABELS.get(fw.hw_type, fw.hw_type.replace("_", " ").title()), + channel=fw.channel, + version=fw.version, + download_url=f"/api/firmware/{fw.hw_type}/{fw.channel}/{fw.version}/firmware.bin", + )) + + # Sort by hw_type label + options.sort(key=lambda x: x.hw_type_label) + return options + + +# ── Public serial number validation ────────────────────────────────────────── + +class SerialValidationResult(BaseModel): + valid: bool + hw_type: Optional[str] = None + hw_type_label: Optional[str] = None + hw_version: Optional[str] = None + + +@router.get("/cloudflash/validate-serial/{serial_number}", response_model=SerialValidationResult) +async def validate_serial(serial_number: str): + """ + Check whether a serial number exists in the device database. + Returns hw_type info if found so the frontend can confirm it matches the user's selection. + No sensitive device data is returned. + """ + _require_cloudflash_enabled() + + sn = serial_number.strip().upper() + try: + device = get_device_by_sn(sn) + return SerialValidationResult( + valid=True, + hw_type=device.hw_type, + hw_type_label=HW_TYPE_LABELS.get(device.hw_type, device.hw_type.replace("_", " ").title()), + hw_version=device.hw_version, + ) + except Exception: + return SerialValidationResult(valid=False) + + +# ── Public NVS generation ───────────────────────────────────────────────────── + +class NvsRequest(BaseModel): + serial_number: str + hw_type: str + hw_revision: str + + +@router.post("/cloudflash/nvs.bin") +async def generate_public_nvs(body: NvsRequest): + """ + Generate an NVS binary for a given serial number + hardware info. + No authentication required — used by the public CloudFlash page for Full Wipe flash. + The serial number is provided by the user (they read it from the sticker on their device). + """ + _require_cloudflash_enabled() + + sn = body.serial_number.strip().upper() + if not sn: + raise HTTPException(status_code=422, detail="Serial number is required.") + + hw_type = body.hw_type.strip().lower() + hw_revision = body.hw_revision.strip() + + if not hw_type or not hw_revision: + raise HTTPException(status_code=422, detail="hw_type and hw_revision are required.") + + try: + nvs_bytes = generate_nvs( + serial_number=sn, + hw_family=hw_type, + hw_revision=hw_revision, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"NVS generation failed: {str(e)}") + + return Response( + content=nvs_bytes, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'}, + ) + + +# ── Public flash assets (bootloader + partitions) ───────────────────────────── + +@router.get("/cloudflash/{hw_type}/bootloader.bin") +async def get_public_bootloader(hw_type: str): + """ + Serve the bootloader binary for a given hw_type. + No authentication required — used by the public CloudFlash page. + """ + _require_cloudflash_enabled() + + import os + from config import settings as cfg + from pathlib import Path + + asset_path = Path(cfg.flash_assets_storage_path) / hw_type / "bootloader.bin" + if not asset_path.exists(): + raise HTTPException(status_code=404, detail=f"Bootloader not found for {hw_type}.") + + return Response( + content=asset_path.read_bytes(), + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="bootloader_{hw_type}.bin"'}, + ) + + +@router.get("/cloudflash/{hw_type}/partitions.bin") +async def get_public_partitions(hw_type: str): + """ + Serve the partition table binary for a given hw_type. + No authentication required — used by the public CloudFlash page. + """ + _require_cloudflash_enabled() + + import os + from config import settings as cfg + from pathlib import Path + + asset_path = Path(cfg.flash_assets_storage_path) / hw_type / "partitions.bin" + if not asset_path.exists(): + raise HTTPException(status_code=404, detail=f"Partition table not found for {hw_type}.") + + return Response( + content=asset_path.read_bytes(), + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="partitions_{hw_type}.bin"'}, + ) diff --git a/backend/settings/public_features_models.py b/backend/settings/public_features_models.py new file mode 100644 index 0000000..7719856 --- /dev/null +++ b/backend/settings/public_features_models.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel +from typing import Optional + + +class PublicFeaturesSettings(BaseModel): + cloudflash_enabled: bool = False + + +class PublicFeaturesSettingsUpdate(BaseModel): + cloudflash_enabled: Optional[bool] = None diff --git a/backend/settings/public_features_service.py b/backend/settings/public_features_service.py new file mode 100644 index 0000000..04c4f7e --- /dev/null +++ b/backend/settings/public_features_service.py @@ -0,0 +1,31 @@ +from shared.firebase import get_db +from settings.public_features_models import PublicFeaturesSettings, PublicFeaturesSettingsUpdate + +COLLECTION = "admin_settings" +DOC_ID = "public_features" + + +def get_public_features() -> PublicFeaturesSettings: + """Get public features settings from Firestore. Creates defaults if not found.""" + db = get_db() + doc = db.collection(COLLECTION).document(DOC_ID).get() + if doc.exists: + return PublicFeaturesSettings(**doc.to_dict()) + defaults = PublicFeaturesSettings() + db.collection(COLLECTION).document(DOC_ID).set(defaults.model_dump()) + return defaults + + +def update_public_features(data: PublicFeaturesSettingsUpdate) -> PublicFeaturesSettings: + """Update public features settings. Only provided fields are updated.""" + db = get_db() + doc_ref = db.collection(COLLECTION).document(DOC_ID) + doc = doc_ref.get() + + existing = doc.to_dict() if doc.exists else PublicFeaturesSettings().model_dump() + update_data = data.model_dump(exclude_none=True) + existing.update(update_data) + + normalized = PublicFeaturesSettings(**existing) + doc_ref.set(normalized.model_dump()) + return normalized diff --git a/backend/settings/router.py b/backend/settings/router.py index cfd7163..e68f5b1 100644 --- a/backend/settings/router.py +++ b/backend/settings/router.py @@ -1,8 +1,11 @@ from fastapi import APIRouter, Depends from auth.models import TokenPayload -from auth.dependencies import require_permission +from auth.dependencies import require_permission, require_roles +from auth.models import Role from settings.models import MelodySettings, MelodySettingsUpdate +from settings.public_features_models import PublicFeaturesSettings, PublicFeaturesSettingsUpdate from settings import service +from settings import public_features_service router = APIRouter(prefix="/api/settings", tags=["settings"]) @@ -20,3 +23,20 @@ async def update_melody_settings( _user: TokenPayload = Depends(require_permission("melodies", "edit")), ): return service.update_melody_settings(body) + + +# ── Public Features Settings (sysadmin / admin only) ───────────────────────── + +@router.get("/public-features", response_model=PublicFeaturesSettings) +async def get_public_features( + _user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)), +): + return public_features_service.get_public_features() + + +@router.put("/public-features", response_model=PublicFeaturesSettings) +async def update_public_features( + body: PublicFeaturesSettingsUpdate, + _user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)), +): + return public_features_service.update_public_features(body) diff --git a/backend/utils/nvs_generator.py b/backend/utils/nvs_generator.py index 58541a9..b2b72cb 100644 --- a/backend/utils/nvs_generator.py +++ b/backend/utils/nvs_generator.py @@ -177,16 +177,16 @@ def _build_page(entries: List[bytes], slot_counts: List[int], seq: int = 0) -> b return page -def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes: +def generate(serial_number: str, hw_family: str, hw_revision: str) -> bytes: """Generate a 0x5000-byte NVS partition binary for a Vesper device. serial_number: full SN string e.g. 'BSVSPR-26C13X-STD01R-X7KQA' - hw_type: board family e.g. 'vesper', 'vesper_plus', 'vesper_pro' - hw_version: zero-padded revision e.g. '01' + hw_family: board family e.g. 'vesper-standard', 'vesper-plus' + hw_revision: hardware revision string e.g. '1.0' - Writes the NEW schema keys (2.0+) expected by ConfigManager: + Writes the schema keys expected by ConfigManager (struct DeviceConfig): serial ← full serial number - hw_family ← board family (hw_type value, lowercase) + hw_family ← board family (lowercase) hw_revision ← hardware revision string Returns raw bytes ready to flash at 0x9000. @@ -196,8 +196,8 @@ def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes: # Build entries for namespace "device_id" ns_entry, ns_span = _build_namespace_entry("device_id", ns_index) uid_entry, uid_span = _build_string_entry(ns_index, "serial", serial_number) - hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_family", hw_type.lower()) - hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_revision", hw_version) + hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_family", hw_family.lower()) + hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_revision", hw_revision) entries = [ns_entry, uid_entry, hwt_entry, hwv_entry] spans = [ns_span, uid_span, hwt_span, hwv_span] diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d5fdb05..95e4ade 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,7 @@ import { Routes, Route, Navigate } from "react-router-dom"; import { useAuth } from "./auth/AuthContext"; +import CloudFlashPage from "./cloudflash/CloudFlashPage"; +import PublicFeaturesSettings from "./settings/PublicFeaturesSettings"; import LoginPage from "./auth/LoginPage"; import MainLayout from "./layout/MainLayout"; import MelodyList from "./melodies/MelodyList"; @@ -106,6 +108,9 @@ function RoleGate({ roles, children }) { export default function App() { return ( + {/* Public routes — no login required */} + } /> + } /> } /> } /> + {/* Settings - Public Features */} + } /> + } /> diff --git a/frontend/src/assets/logos/cloudflash_large.png b/frontend/src/assets/logos/cloudflash_large.png new file mode 100644 index 0000000000000000000000000000000000000000..d63918ff230d771a865f3c80b0f52153a91fdbf4 GIT binary patch literal 105111 zcmd?Rg5zu`-R%9m?nm6u{{DcE;}{rbxaYoBTx*?ct@9#6M@xl-h@J=w3yVZeRZ$NM3tt-x z3x|>rA9$r)zit={iv>$fQU0c9#wtnx8+frP=ZE&1C4>cd3VapO1K%*1)n)+_;7N1N zk^VD#eD5(0G1!KybMsQEv(ytVZ~2yg#)t zf(^#Pf;5*L>HA%t<>0%~1I5Ccsw-F*xPygdX8Z`y7oe&UY(R~K0f?iQRiU*Fc+FCP z#_MaoiG~6Ove!FT@%bO2&OZr9U_ZN7ols8|f0G}c1-xTgd8T3al1?5(g@t9H=JH>E zONd*#E?X(N%SzNi-IfZ+0v6@aY-F459!|@Rg;m1c@!Q|>6tWzso-yWRc;aAT`BokB zCiS$yzqiI}Qj!BMGIci*c=>!KrNBxEaZ#(XmF%iSsF|W(oavTxxt{O6u|Iy3^9dd8 zFd9nE3TS|jgYG|fIrkI@j{M1+qF}B3WvA-AfDeD(WdOo3`uH%os&OEiTLF-_;^!5m zBv^8Q46CxjJY>6{MBP(^m@>~VIjK5T=abJLH+`>||5@sAQ1qIB}SXl4ToO!uZ+Q?_4wzXq=}mllp)iuYGY_UG9x3%OoO`4 zYtX1y737e`(33S9u6X{nt9|QXENs&0W83Tq8%eXSsM@Eg55yCPf#JKXpLm`}U@>Aj zkl+8f(6M;MnkWZRUY>dnA-Qzj!+Xf{Bjr{y^Q=2`f8~o{wl=()e*Y$wM7>fXOt+x- zaYIH@`1#ijtaIrx82U^pI=ts*l+i)D2qI+^7w!2k{Wv;4pFrIv71ojsuS1`Tz-gW_ z@$jURcCLr<@>x7?HMWA_!J z8N{JiYS5|)%onvkhlP)So+$pOCUAAifF(@$?>#LG$cbkL3?mENUH}&tm%yYsd}+We z(}>kYFJu!*^r3|lGo#3YyR>x4_02ays^Bx~z`O6h5i%U!40~DGpiv1nj`AGxf14P?jF~*9h>PFJI z^YDQ+6~)U_Yi&7N_WDuoAWMP$1jIX$h|nsQdaC#wid_R)6A z+Er>r62*2T`(w@;j?PX;U-(o%*G- zB4gP7l__z~Mc&L$mH?vV|JaG~MD3Yk<{5P^JaM_Nlz4@N)cF8gUnyYi3$IoQb`}C_ z=_i)GK?V+XP;x{x_+w-acc)LAlw4o%XNctPFy5r~6GYOAuvS702J@n(XP`WyKR5Y* z%)JQ$cF%ugAI~KZKtSXM6hGbH{K9a(6tB4iho}q7i9u2)Sl(G&HEK?F$c~TLmwX0@ zZ&(2gEz?#R`vxKXuO7@!Y&xv67NyW=8->li%X}}uSz!6?Yr#WHX(Q8z1Nqkq!`WXL zY)JlPkr z)P7cMB7q@RlVp!|YUPaWl8hF!^?gtpIDqGQA*dhV{tPRt*H@>Tzn8b$hpG%THgi`ygrJIreM8KSt+v%$J z_}QaXYJ0I-UZ%j#VnhphzWvIlxa<1QyhO6uwH=^_$8$ZC+_f3^bIz|OP&?0z=O!-& z|7r3%Fr~iJC7)7vW)&wkM${FSpqg-QdwU3dQb5efP<{>H8l2`YFM|(ETcuP9c14-o zO6Pz0!$DZTvE8e5CgMthT`iw%76+2jZvRoL=FrzKp0NrzH*RQbWzKYOke%CnQ=9ES zkPVPHfQ3wXx=t^%1^uZe;JJ7Jk5~kc7K3xvpe9w(%Ui4j3>aXkV zhG?K%U+*Ws`6C)Tyg~7#d;O= zaK0PmAKrfdsy*9Z-1T8Ht;MccBJ_)nNxGpA0Qs;mFgAkoADGoq*-LT=%C@bx8B_aS zi)*w-O_fG%ECW1Z!G!u`b{%$iAT!6QY4sI;uKr#~077FmHjFTDrrE7kFui;qGgAXi zXp(so!~S8bYdxLqy`Lo6!~AVA9cLu>(7U=qFj84P?FjR5`qXP7iYR!Z-PzJ2A8e5Bo)%fmd?}y&XJ$h4Gx|| zxi0LxIh_C6cbY^vtds;!ZN;VIi1gmi*mo;dZZPILerA-ASJYKY&rwi;$KQ@k66nu6 zz&JgZ87$cB4P9iTD|hxlddJr@o}_4%_g-k$q=8-q_PCb@q?7E3q}*7#8H zFqi1hy+ZpMUI(Dt2tmQlPi%wXTm21y9A^hxr{B}{`C}(PK3dx@(zJ7A8m>HqitOuo z&m~8MT#eJ$K}LpF)o0ZtN7IRt#$6&7BG#4sV{`omN;p9QU1|d0QOwrV`a67Cl_V-f zjSMo$(k8i9Q0LP6#k3%W4FEYn`TqNb$r2B+&M&?pB{^Af1@B2om@ z`mdVZ#J$0f4OeE`0Cl26R`6oF?Wqw0LO%UM6+p4De>ytE58oKCuXi5Ut!Hl|T|Wz2 z+xE71R2GVS?Z@okXIZoxbcILL7?)WFTV4nTgYmtkHXnJS(5XmNZ~5`;Xy}(^ zVWrfeEsf|Yi_hLT_uRIveuZ}s2?4 z!{f3U!W<&+Qd3hoBTcY>0H$j&mGPhOKV0p|`kqlUqHd#I`(tkU<7CZPxTPT8C;kR#ZOEaQBv;e)EUT^XCqh6ka`_hGkY4xks~DxX8rh%*S!MK zVpFCX=N}aH#$pFe_%tln4i%Her7uJeN(j-<)zdrl$NqPfb`ByAu(+YH_I%I79i+UCl-lNvwoy;wwk>-Oig?sjy3 zM3=6CO6wNMGs`V()uok1ZGuZ1BX^(CELQJKj~g!4X{Pia9Gs}~=5*wo0Wv%;HkH9p zFgG;bzk_#A*IkPb?`^rHqE>#+8>L$WNmf?ml$4+5W@*ISR{JO7#9&XIG({4KHZ@IR zm?njPRct5$kc$5DS$c=|->mKwFqR2JqT4?fCwP%CfdyJdb$N3M>rMLyYCVe$_lMEF`3-t0es-A~p4Lg~NGOuB`mY z{kg9%#?iGf>{iDcn!Yo#(otl;7Nv`OZch1DM9f9c^Zd6s6>t`CHwgxUE3qZI7&v4| z9e|Y32P5KQhj$DNylCm@M&xMHF~|FH(?Ef2D(io=x%X)V%6~+!Uf&qEBH=r(u4CYa z&CbqFqnmrZb@O0Dwt;E9#;u4)wADv6mXWi@J!NWJwzMd34@-8U=6h;oQ*ZaTTx4dZ zF%zqLlOj>pI9H+d3C4By`}}Lwhog@anTC`)fMC02d|`VX{GFWtAuj~f2c*otCyX%H zU@;hek7&uKBJALPb*0?Z#ZFL2=#8L|KrsHwJ-XBsBpofS*!km32vH&gfOr(2KM*x! zm%`r`13&$D_WQCW{U2lMFV&W3W(Er-L`1039fym~&rZqu`!+Z^ExvfM*SY$}Gj^8E zqZ(X`t2RITNu}K}!;!b}{?H~#MA6j3v~W{AS|mYX)sgbrqlU+)AB0>jR60Mr^3zsL z;br~7L>W2&PQDP_-u|nNWCAkAVV-c!1xGP17&qrjR4dB7w;OHW33z^2dplPb5P6iB z_!2QS9Gh5)8=@%e`p0J#{yj5vSBYP`OBc)t7$fmlN=Y`M&q_6UghZ#Rg~e_K#Z1@z$x+92D0@a;<2HaH&Ovn;!kP8UHnw1ndQT8izUU zp+nL^gB3sCiHnJ40;+#YM@J_Ns6B`2UC5(EWVkd32gD&slIZO4b6BQO-OAgmnSy5DMtF1z^XuQ-l~YB>1R^`g@r~+qOBj}aY+xF+B}I!h zU-fFgmumtM39rks!ZcKX=<=WL*nbM=sei>30aXEm5zy$s{hXAW3&^*6#!NDg{gq+X zru|4Tkl-Z^@Z7lQCpkL&$Mihi$gvb{C!4GltHf@2M4r9MTeYJwW8`Ljv;mLC?4e)RgHvgF4z zye`gaJ%jAe2j>R*8a-%lL4PAA&}N#z{8(R07YcoG`-=T)#`aPYJ4DPQbxP(n@u>;r zZ4vi7!kU?OqhE`C!{LlGL=Ww;Os$}T*|e1x<$y5#ogRPhWCk2z`LTf*r^X%fUcBq8 za2vWqYK&Wc+uc1@5MLPv#Sg_~w9`9jCQmU;w+`n9sgTQ*w-1YMt}OPwv7^>{bGOp$ zJF!#}tR{9fXlrZDg5{c5pl0+i`>Sy2vdH{#J`YEu!``M&Z#DJF;eaP0w;*0V;O~lr z56l`t0u(eyMO4bl)Y$Uo>(Zm5t7p4g&9b~#)KZ68M82w}M*r4y-Te1ZR2z^t4ja{y zt=NrI{cd*e-n)BN7K4F|eLu3XT?sEwDuUg`8Nz(XY3X0iWfTC=TUxfn&u_k>_q(Jm zltaVz3~o~mF6tomwYT5i9rMCjG!^q(eO{3LAl zD-3bRwTDllQQ7srb=6gY_n|^U(9U%>Wg_?Ijf1{Nn~(KV>XZfmFp0@ot3f^52Mc)Q zk_n|Mav5=_CSAt+)oFm885jtZh35!++)MD+#H)<`2x|lim+X}WPg9X)bfViC+5r#4 zOKKFkqu`VW>WaUH(tVI2d5h4N&8+RsqBczd!RS?V`7CG%jU!^IHhw2ali`_!eM-L~ zw#aemz;Mu_>|n9k);qgr6ML5;7UrktMT58-Ggwd!U7#TjqRWzX$@vwlsUJs0Wo#Nr z2sO>faWHBcm|vG{MhS8J>E{;oF?kpFD;NHpMh1d!!uhUCU%|7akzsLagdpO(d30|u zOgLWPCjKQ1CakOZ%M9&&8-lpS2*2h*#-^-*uGK2ZTu0reWIeD^1_yI`WIUNb8T zoHY@1>?n&>EY!z0phYe64|S?%mYvfhE(+`KZvd_BQ@5F^qJ?|q-pni{twQAD_k$dT zOWT}vYtxZ8%bnhn==V>P^ww1uo%VkcdJJQKIoI9Zj*fl!3@k;srD4gkp=fSt13e#M zaBj|6T4l{j1X)1`J0)53B;ys$@R*M)f6$)zs(?ULm~lLmtjhJB~LGnL60oQOX{F&Nz$Vq@m;=BB1A zYf}q$VU}TFD7AoZoephyxQhLLBbnZB4d4IP|38FGDY@+_=gFX$4}Uz}u(z_d&fLYw z27_94jZ~ow!!Y&mY%ck9j$9`T5F<-B3MuRVxS`xa3*&+lypGm*5nq;$1k#>D5-U38 zm^sYEWi|BJX0{Vg{qgCM7lF8D4ntSSnkqjVOqV0y%IM~`3h{8M+9+BL4c|QGNnwdQ z7j|PP{@*b8cl3_4ja3wA_7(~+|H?>t`?JW>JqwEtbF+g!g>GG=VK`rjU84t#Qp6R} z(_&6E;LSSKTdDrX3y+%_Eduuy-s&ERG?Ue~GmdTRvEXUhn7dQ;2@VS5v zxN;c(Ui&||`Eo#hMAE5)6@c>p;sS(>YN=m&{h-&{$|5q1u><+Y1N@Ycd8zJ((;Am~ zV|f$^AK@OwVvIv7L^irktw4)k?|16!=&rq{B^bBNcFjD{K@T~lb&27-)3Oo`+`5|h zRh=o}`xQ*ntMSYJXD42G;##_&a5{V0XZW7kn3-99{wi9PJ5#V#GxMx}9cpjphI8*% zKnCGE4>|oya~*&0g1`21K6WBZi+W9r{VF$)Y-V8K0IA=nU~Io8TsDP0D==g#jU$k> z5bByilhr|Me=ymA@n4^)DO+zX5Snxv+YfTc$xM!b8bN80Fuibbtrb~n0lhe!>!MLN zMNwZiM(fW`%T@OE^D-Jkug0)w#;*_ zOEHCY24|BEj~trkh1%*95eJ&z9ijGh8cu79s7FTB0(B}STh42bS?8Ga#_?<(8VO+5 zfHs#1yL+mfRcQgKXQV=%5tARB2&l#4YM<7#Mo4OfOE(eYZ?0*WH|9}arp;8mAObG& z&so|(l~iJK-MZJ!9?Sdj*Cp28CdS3JB-;Z+@uPtUFO-q1_UyT4Mb1jg3+71>xKr1J zHi^}<+K>e|{s2fljzw9LTP)`Eid{kSq*9d({)N!i>}idUesdv zcD&y=Day4RhE}6=^OD!ql?LCxBSH~7o`3>@I@g*qW*dLgvr*B7;nJwswY5cLHO zQi(iu&wtpjZw8?te6-v@Gweh@^D?2-^45du9kr)PK2T1H+R;isJ=II+y#_2f&W0`V z>Eew;IgXC_8|s*1dP=RvA5vRu+nZil@jmk(v=v9+Rr1*NbW(ad5S`}!Solo8b1$Ye z+O|zhyP(rCxzq8Fg0wrg?Kt%+3vPNCg?npcDERl8!k$;RTVlS5MF{izHt);IkMLOJ z*5>@tn|Xazst3mKkE7(1kE5@XBLwq;j}D6Y)p zsH&Tk8TqQ3wxQ%ZW1O5>8N#`ew_P^9%_6?t{T%(n5dY7SA237N zn9fS$zosnyGH-5KdBQ)WBW7n?=_%p&qM@#Bw_%T}^jkhMbbHFDy%thS>$UHW&U_`d zAD=WtS0dN7tXv*kdqQICEKNrk>Q?++yKXG2u$lFESnKMGS-nal17eBxBxpkV3{5H2prRM`!SMTO$hLprPc*ORBLLz&qRa$sj zkiAvMJp>&ItlIpQz~Tp{8{J22feOroiP{{*e`*2?Bd_T3rzYsfkEAZZFKkD--J+yd zJo`l4uUFn$TUk|vsl`i~E3{gH2~Q4zUfh1Msg~?V|HY}&kA}VWMiR4l5h|SKAM5b& z(FJqEs~;8eg(2O8=z{sm0}!&}&WKpPKjvu5_pzzpe0>j(iMccNN4VDJnv8j4Q>(J2 ztE5c81`~rqomh!xUQT)H@Q8TIZKe+r31MOUgw_!D#N}w!DmGqoG!JI2>ltyOd1 z?~ISq>k?L7ov{n{kDh0uMEf8mcf7tuV?Yml~xB}P;ha8=8 z8uq$AV_$k9$AE3X3?_W^vH><`{sRz0y_h(+x0itKR4j*~zPY~5t1wl8yU zBk}7>Nm4$$&LC4dphjmbRA!pYs@IB)xePoq8<(Em@A{$nF7v67pa9rRD-l7FqKM+_ zBfNKxhx;Zc8%%xAPL@}j>qIZD4VP^1Rz2!6u>||*!E{wq``JWfez(c#Pn!U3h4qWc zxdg|;oeI(Y%}K*Z&cL1?U@}k?sn38lQ_M2`8q3({F%ABv6AgZq4@SRxX)bA zk)Mmv-{Hwxub#xJ{MzZ^0?hMwJJ#r8J2n6uKJln*d9|G0+Iqp;K8d9r%7TPx^urDM zf1I2h?sQg0y?jwIr^0-)b1aJc%}k(Zwzhwy=H&V%7V9=3Uq?W0zyQ2IF4v6)*Po{{ zDW?c%`3`8Y2ujw|i|Y8`g86bkW9$PqM72&?ht~qB9XB<8KS(MfXrr(g;|uf1|E_-j!PltBq@ByYZ#qajGS#nP=xEVq ziNhYLWTNIlY#8REosF0K5A}-;uiZO(!6{^7LFZ)C*re}U=g-BV$|F^|KlyG4}0O#U{BD_T_uegIf%JS^w| z6T7|M{6;kpeWYq!Ex+teyzpDpW;(o4P^V4nv{^a;5t~6~fQv7>6ye{r1`9fp7Z=|S z$KP~b7)xUkxx}}QGmHi=@Ikj>8am2Rz}(^Fq$8M{WW-#3;)CbWvvAAz@%P=Zpl}-< zx@SxlFm0zbG1hzg;9Pl6`*k*V7vx6IVD7$;SSK+%?E3&PF6oOWhOecIoN0B z=sjSzX!l2RboyIC&9)(A;1nr#-b_ISsjz8)^cO+JN`4SJ0TuKm5Q%a#OpAqmk1P%(UK5=Z7y`{Sn8RvP>{Bq?8AN2W$o zWA;RY|Ir3O{VVAZRXqxN!M1xX{5q4Pc;bnFp%ku*cgUNNeDy*GD2-@bW>G>*GD%pm z`>hJCo55?5DAzgK)=TufGfQ=jn*|TuL_}mte-{(~XRly&oaa)L96+X2*9_cLHJ~V> z{R9dIQCA_w9E= zZ?Vt}sAq#dSY%`0pP~!tYYcGapy8_o+8)4|XKd^Hi;T@?#Y+;F=?5rLGmTCw+@c67 zqX*6dOHf8MFl2V6lj4&#BPPCqKhW_dN{EEqoG;Q_7rcxKeY%&R5v_r`= zfVz3G;o2vG4`BS^Bb*9Lw0!oGT272TA?mS|8W0wd#}RZ(a2u?w{yB>J2v*9oLnuMR z+41p)G{;r>WHZ@V;#*AT9mZi3inl6W0xXR4O13magibD7Vs|9Hdhe93xJ8!~((-^+ zJji|QX>#4PS2=oqn{Bf&{ar}@UcXZ`R{nvD@AS?(>~oSnbJ^6?Owm0cN5I{mfwdl~ zJ^<1uX7O3>Y!5-Qtt#sBrIbZFc=fR<6UP=>h;m;o{d-D!EJrfI_2^AcM;}_Wu%!s z0by}PSq7WTJ@)qQd85JhGzKSq#gqI7Bg4~IP>Io)=_H1nT>_w=aQTFpjWpr9M&3zS z2Z~Za4m2=NOP9O6D{HX_VkH;}X*$RzhxK%~@l#S|-{E~(m^aaHkoXw)I|Rn-jEM{M z4PdGso*_4tN`U%b2&MV|<+i^i#Ad2^q5DC-Dyd?r@0as&*r8FP+oekA4MCLj`|X{! z{iruK`&zy`Gs~t|GyzUWggjBqf>0z}JATB;Nc{ejtk<#5V4ll$LI6Bi}Q>h+ja zSC$}~%l|6-%ETwGpL!7dkpJk@CvAvLxF1hvbGcfKqX_(U^jP4iI;2A*v>57SZ30pI&o4QH` z({j>`J!x~!2G>j8;6e>|qv!F(*6(tCO=025!XYxB>KfO2S;AWBPW!n>|q&8$6C z;C(9AOzkm@CLAvg%wnP~X(xv=AIwBx_hHE#IWi!QH#R1V&)6sRgPy*iEg1{LsRoVu zaoH|!$$s*K7ap-d=8m;=#$c3%v7nV;}9j0j@%{c`y^ziz&qsTg--! z)m#`3uw(z+Aip;+n(=f1=C}hH6Q7OQbYoK|YMtb65NY}~`D{W|2I4SILNef<%Y5_b zke%TUdz{|=8Dt>#7f%a5zn7GlmOKo9ESTQN@n90+^CqGY=_bk!eGcN)!C1CT<&e!# zku9$o`*o8W8s(OPN$R0ItpV0Fd@=&g$e5T|fw_8s(nIT&cs?;esn6$1twMPrPU6vk zVJV3c8M-eO^3{kzg~|aAlH-0*48dusMr@9yLJ}t$&FU?|g8v)e9%uFMHq&oTf1ObW zAp9&g`gkja!Pwc$sp=!hKGf;<6O>>uHU~iM)V%g)kuPT(4Gc10m!A!o0qh_om^Iqr zCGoK4S0>gBm(IJ7Aki8Mn1LorX!A=FF{C2rWoYCmvV*}F?qPvvcsI|AENf{TZYtyF zjaPewOq0}{a-oHCqBj)eg)y>H?|^z_i|5t-YW@kAYqVYK&^cq+oK_)%O#Tmg*$exZ z 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 KvGS>T$o~Kpx#QOW literal 0 HcmV?d00001 diff --git a/frontend/src/assets/logos/cloudflash_small.png b/frontend/src/assets/logos/cloudflash_small.png new file mode 100644 index 0000000000000000000000000000000000000000..fbd08a3f15bd165fcb7eacf3b98ad542489305e4 GIT binary patch literal 12409 zcmZX4c|4Tw*Z+($)}~LS-4tWE+NLi$Wv{k)@$zFt)4} zQHilj*+RxP_jC7qzTbR4&+ngk&1+uwT-UkId7t+==Y8D?r>#xdScF(05D1&OnUO67 z0@Vb+2QV>!pPa+(PzVGGF*h=>3(fPoAOJ7I-p1bFFv2?pf1Hn$EtB|Een!a=3WLGG zrvX4nVa5b{0#f~*DgUHXiE$X5_XI2XjM6CNUk^ON-s2OjV9^O?se9iVs6ofT@Mo|8 z{=EVF1t;E?uJ$S(Rb^oi_VDod#{*^9vDRcR5)m{Zx@TYwI?YC2Mm^|_Fo zT+#_{?iwhnyudq_=k~eHQu`K#tRN_!_bPL_TEk0RLdxyn$;A^nc zDHo?@0TileBeIkX?y?dF{C;xN%WyKY?X~*8!N~I~ejQ`SFI96iV?9*4xzkm+QK<5& z-SxRyRRDSCVNB?lNq}7P7@wG$uEg5^cNi7q>&^Npk`)_!V4$$1Bp*K*({=!Xm=F-4 zC8?+=`ICZy=E%sciN-LsLhbGq-g|E4@C&XJVHgW3LPNQ{JbTrrG0>{3m2OJIp0`8V z_(hgccBeTlrd3&m^HZtP0@5TB=^rsgq@E)we7<%rD$czCpO&CW10or21}Or7c+K~X zm5C?&&JGQhS7i61eZ=a~?3SJL_G?B*W|us?d_KW=gDoxb2zR|m>Ghh^Ip$=dU^Pxe z`J3Z)wf{Zap}&Fh5@VyD(GP^a+8IUJum_Ic00ad@Y5U$D+gsv8PP+q;l9m7lf!%@U z{5CPds)k|)n_g{}3ts#-qO>b>6pX=XBkego80;eex}sb@AaUYt`1#xWQ#pEmxWlN- zXzpBrMr~y;a_oC)0sO(HLdn@@u#8GmvJ`u<@P&6>t&P72CL(PkX*6FdOtzawi}?WP z?Qib_I@$XxdTj&w%~|vG*{3KKgseKh!LVe2Vq<%Xwv(nh~SQVMpADCpYi!53J7iWc^yy zcxvj0qwhzP2gjc#X2d)tyEwVrJTt1mb;5F=4sflYZx!OIdq*$z7}8zx^nY_I)UG zdUm15Flg<)vf?Jy@9z7fM`L)Dl#~v#R}9&Rb-uo8Acj)4SZa<0;4L_z@}OM~x9pzP z-L3r34PvslVx5dDp7+mPTVt_PR#K`$$r}dr1Dn%79$(5>JGb>B-_V^ex+fgi>|N6Q zmHRYnot3GijYZQ(vSVVNu(Va)mqN7kfyP-VsHwrAYHHj60{c>B3S9sevsP$Yfw1fi z2hqnLJ5bQtL8e7#B`ED}?{4W7(p6%%a{|;9X&rizKn)P}eU~vu23MW)nK<>qnctc( z?VfM@hhcKPQSA2*-7(GgzSTa^jocx!DBEx4pR*b`l7?TFEmV|;apgm;z|i;{gyv7d zhTS%C3!)f&&|~QMr8h{&=7bFc_U8vu+?xUU9q`KzxT)LD+J2{K7~JL5I{JiKAnoJ~ zn#*TSoJ>!b-PhVLm_4Q>#T2g1o}BDPQC@P?X`0c0Iq12myj03$g7=fCK&fMVd5Z!@LCzmQsgQ=-t_Dli+VHOg-O~^u!bT? z^u2F;!i=$wg8UFaEEr!it$!G3kxJh4_VA!^XW~6CPCEj-BODAzRI8!cR3JlD%l$P@ z@*!nz4s#KE|H2!`4dt|3c3dbvdsdCqDaMh08t!}Bho%cV1LSg!sZG_zRNdnZFef}rk_dQvXcP9m+c_lY4;sZREI zcfIh?U58WbZ_N2_ABR-K`yxgB-?yjfv}k;JCXD`9_N$TnqW1Q;+`sF^G9*9L8CQC} zPw&LUIjl0pn|1+MCqyMjaau?)9u-JqIVsrzAOHbDK}XGo26Iu0@AOm#gtr%O88jQa zvENz9eqQ9OH6!|uBVh-Q1hK@9d`6-UkVw##oqYh? zR63z_xMNNqF1p~+8#(xCEMG5DV09E1c#N1jImurSYv7-v0bQXtUm5d)3yYwW6J)fEgq16`47rM-TpCu z(=misyT844JX`O^)01{=?-dU7vR?h%3<$beC}}FL#!f zU+(`#{AnkwrJgVRLE<5xC^FHE&(4$-do^uqQ@ZMnEb!q$&>y%?M=*2-5}+W2co37c zb+^3H6Y1x}^X0+x#rY}L<6BlchYO4MS9jJ#t8(lESOZ(KV@$E_hhM&#*W*q)Sq|A@ z9cSr)Ob9XLGU7x>HpCmF5+CN;%nlG%hs<7!(#*w$RN2i4HhpuqksL|%a6Z*j_nojiLwSiUtXDr zd_mg-fW3kINF)tt1^`ubXRE+ObLi;HMzsYkd+Tw(u5gYjF4zawi?Ltm z6|<2&CY0mcKU*I(HwTJ4f6l*+*?O5ZS;sn7oN?o0PL2SeTBB6`vijwzs;a70gRN&O zRr=raCv=wcEj8eOqE*1z%27 z(b<^(`l>h*paE1yISSI0khH%$PzvOyF~_ozj>FPvdL}+iT2DiwQq+A{?Gpc8$Wf+QI z5cAs#g~z;W1);4Qdi{sjIm6oXOS!%rk?9muFIM%iMU$4({{XYAlsZdL^xF$R zVt#YNt#9{F2I9*_biqWB-QVBJ=dPb+eIqxuqGxgl*qz^^D7b+M)3}kJW{%MP)B$-L z#Ct|VpC5=mKe#MYp38DlI3*`7igRfH0+rE?rFC`v$J0o?$gt)PM4Gn;IGAisyb$LY z*LfYXu&|ROwc>h`Cg`J*rfj~5nd_QMxv3c^bP%wL91{lQZEf}hNrvf@@AkOKZd z@^uAMXR9K}|CyAYKvpQ)&G*vw+!-V<4MNSTuFa5oO!TL3;PD7Oy=K>hC)l)66zZ~jDzyPLfp0V|3y^FlbX zh+bEA3109~8Z0;@q%gz)I{*~uZ#Dg6dGYYkA#a}Rz^I0$AkE-d(cKek`ogP)^S@M7 z3~Z#fH>W=&AI)eG7k4$*y+z+t;H~fNEqttPWRvX6P)K+Ge&DjSfaL*@{sd8lWM4)3 zdV>^S!v3UScWZa6Ne>U`M*l8WQBuOFR98Ezs4A&cV}CzZiK<-pq8F9e+xXmabX$D+ z;V!yh^KPIzEHgSjyYlGUOp!wLmFt-|O8*gcuy$%<2PLngr-K)~$bqwTHMnru;|+Cr zAyrDsDlb*gDP~@tUewL)j+W9p&xMaoReFT5D&6c$B`$~kOv-c!hkZGzPXur_SV%K({K@PRBU5>F<%hXDS$dtT}Lrt zEie2(;O}r0;uM}1T)nPP&3hB^Q=6{7>O?SdXP`6d+bf@^6;j^vhjOf#eMn~q3%}xe zBD_GQA&UOrWmFM+#RcI!l7`GRv?n~wz*#-w40GW^wE^P!ncp68X=cQ)=>|+K9IsGP zTYtZw*mtG)T{4)s|4s@O3wR2Tg@=6rxL4o+xw%HX>S37X-A2^&O4L?9hqP(x%sK%@bC@U+6 zZ3j*JefgSxdHmjct9BnoO`HfU;WpD}ivR(9bQIlzN0@PA`T;aCJU082P$B#+wH0>R zK)S%Qt-s^lvyJ_wqP)1v!dK5o=$0xg##M6UyHcH=fs-oiz#EENm0n*jcbh$-OA(v< z$m((JSlN&HARMDTa{iudPiqGT_%$aReDd==jD$4$tqIwEnm?8Y2v_DIVVyt=1 zc@yqm{-rFg9ssJ=?~Lu1S5?=4oN)Hpzahu~@Df+8aasDgLj1f(0j{nUns3E^V~s zYH;u4kd6YA$-7p0sh{st)3etM1Gpi@^@H)s`9fHGI87Q!f52?LI1!ng^dY?EB?%Mr zmJ`{HUy7I2kaZ_a%Y_H~Jd9-hy1u{s!0O>FpWH($JbBzmK}iXx{a56~<3#FM)K78o zmMPqC`C{D8SbCzz+>ccNB_u9PdcxpP7*(-(SJCD+LKD`GHm9Z5P+$wyZi%bWT={ zsosi}7;Od-&x98qEk0^8Vb}Hj`2}6s9Xu_QsA3DP_qg=ld!2x-!Nvai2m!_hE>hT< zEQAwPLD~^H{c{lZ`qn=L!>Ylfh0BqeHeV3mLcKFLkG#U8B&A`dnt_#m+!% zB@sb49NvF3eHV(7P!eG=wSE1Q+-sDsLdKRo`n!~hg<0VE8=2yQ0t%R_#j%yEpaSX$ zNx+WU_^hJ@TGX&!YtA)A`T{SBa@FXog}r zL_&xRN*e?0?$BCuae{I2d1R8k2WO(q?b?~UrwUCICzv;bS9$Nv;GcrD{#TgjLbJPL z3%d4c`>*qGxF^X@d|+d#b2qk{&g*bWHlHYrlhfF|wZy?QG1|H`^d`ES9P)t6dxu%-UdeTb{<0X}rIxC~_^AM*0uYlR5f272jyb6kJ zHzg%YRQ%8HfYK+2rKRNuvaA#uKS({K`Heq#8fU+j@Io%J;$mEu0>%AlSE2v4Oi9Ik zU}ZANjYG9I$Kj7)skn*In99CKlvI0k7cF60Ddg2g7z?^v>iJi|FU=G=10NfC;htP} zo}{erg!^G;d0_?-&<0UfFbMNGW5`wqPO+jbpG0%T>^?=6iq42xw|(pi8fGKhz508&u*fG?g#I z9f2_i7KNCADa=@;%%UTCGw=a#aHQMBHzV=|sEi>mxl~H+QQ0}n`@_pJIY?AJ^6>FT z$1PL4mMz9WHfYH zJaY8tmN&QFjm7(q%gf8@6vG+WMKr_(1-GN1wwwaN*RHjFOYU>+@*>U{aO;IEkKLjy zE$ttY?Ufce(SnUiFc#0!q5=EY5+;FW4ry*qqi7&9QZMY)vptnw#njZ(j!&W7QhMTJ z^d=>M2SV)i?(fN^4{z3szrbLRFD0=F=A4(f6qhC#>9b4F3iPCmOk2$B1zg(2dYQhX zGc>Vhj{<;do5yJ^5jxtDwCUf0MA5_D>!dkv)wzkM4jki(a*$c&@(Au8m&y2^9fyyo zDyyH9KfY6Bf5u-Lp6n+e5Ws&fYHIH|MAG({{o6ei!UT}6pWWQt#vACJ*kT*)5JEyi zNnpoVQgS(Rh$;}Zx3_)G6oXmo2Cq#L$#;bHs2ktY6=1M7Lviu^3u0oA zBm)v7_jllpQ!7X{&G7JjA2qdl{ea*C7Ld((Lhh=2S zaFoaTj_gExEv0vJprZv{M{g#%11ovie|9r5n(@fTBi zwOi8#1WNgv!PDr4jed}XUDGRF_2f)->m_xWWrSiK*MOGJrEjaD4A8KfV2ehMTJ#U7 zI9C1hM3iM-SQ5sOURL0S8Agz^A}~yKmdJYYFTfs$NSK!LGWx2js=lVE&>z1Qv@lp$ zN6*l(_TFQ4;e8P#EN8nb4V7EKsK+HcM)_G}N{~HeW){#QN?#oFhapZoI557_Zy*xy z1qrS$e|ya~+8VkzR7OW+(Ktl?z4Vo#RL=l@9a+bsA&{DyS_*1sj`aQN0WKP>4M1ds zC|Z`XdV_GL)>CjIiigMiS~!LmOu--Bv&o@%`Kx?HCQF%Fz~d(g3J4?w7IMRA08LYT zj;}D@&6v;Nd*9HjX{|2yAnh2eS*s;%47UJqyePqWTG=3#P1GupW54&XyEr>3uJleb zAqKC%IeY)dO#eeIDROUbUbF>mAYbotQ&W>3m-Gx%{bFovtVq!3wcV|iq`^SGm*+?Q z1@B|QoRK};IY1?49djooFbvgooP$KMM3|d*vvnm1FrW<$F_LD7G%!>2VR`om1P)*R zSeMoP-H^ctCrsYohtsGm4}BdYck;xl`> zg}EcyF0s^=iX}}D1|~6`vX%FYShXT_8t9(awqSGIWjV9H0L_t}Ru+R25V(mCUtY%C zN>)fJt7&k@lgXQDrw2V>f%S=YGsuS z;pbnrZ_ib~%6HgU<*mMn%*N>F&lnRLNR+z(ZGRW)nwOkx6%`W$^NrSu*wZL&Zvl%F zwuX0W^T!BWgX?H1rPU!@JP3n<`u_g?`zF-dx*wu_Q+{uIeI9llGdP+XA8#hA7=Cr) zEu25t(uM9faYzZP`p_uLsJl`z%s;gSXl_2%{B!Gft7PYsmA(OT$5N8T%EXHm{%OA_ z)9L|09pqD~P>t&NGD_ufV83ZK&{#Q9M*~vH zr=cqppl7b|kA(1*h#UAQ9V{;|ui<;nVA>RoM*mpj35sPosidW#pny%|47u`oVxk!? z!+mJ@-kr56V{JuM16OO14j2L=z?2Awg2ZYc^2Za=adDk(b8|itVerA>=ML(gax);+ z(EoAu;+^$5HW&A>urQ?JR*X}kwzl>{g{;8$@ZjL!qz0>KxZrus{h%@w zwzU=w1lnz%4WII0GBkAOkd^BKVc^&cUz0YUGUdHiaz8hrr#@0oH>%Uk zTpGXHNAxOMgIbRP~Cd?bkLeVUf;%}yPqXFVw)A(2;+_`H@gnx(mU%Xv~1 z3ld~SW2H?xI-;|axL$-yY|sAQ-kt^l-(ClS96J8zU4Z@l?RDSNB^RbtSXSE{7E)nv zMO=v@7ANV}bBOc-n4R5`Erp5x3LrL9(DQgdqNA$=_c1@TF$B@7e0CKduB@~d7RoNh zi2tb_I&C3;`JAqd!RF@1V!OM|3zMtc`@U0Lty<6{ZbaVNM?5g>N2FMD*y~zRdk^o+ zyxep|AxA&Yl+GEd5-p{R4~Bn)jTeih-{ote>B*>-TE~)MtrSub^MKH zY!TL5{~Z&eE6VNc*|vf1?gZ6IeP<^D_-@_h5s_844VzpwwsIRIBYNMZ?Wy*pcA`pl zK>@Nq`bS$FM^JcA*0HS4c^WIfVnBy|4L>h$RO7yev!D9aar#%;*&5pz0wE1b1NIu1 zc89;^S0$INo%HEaeu=p?t+F}sYAX)I6NDhD@VvSei+p>H)j6`c@in5grA7>4zP#0=MZnxRr)gJ?;R`|lKxCMX6jUc-lGZZpMNwmK z{d|-W%BMVgdky{5p+`k!+Kr#O(#cO&f^lB{ZpMJO{8eDT0s171R2%I)d>jJ2jl?ET zr=8W{e0@E(v=FZGSD1P1AQ4x0*N_nefO5yH`k!B4U0rZJSMKVD#^qI2DfJ_^)}~Ee zyiZW+vd)}7eaN@<7Fa(&*^4l(uC7vswr0AygMM^(busxe?5<9>-Fy4?t&QJY%x6ta zO%2vcPz7}j#$Xx{XOjl@U$`|nMa#W8di1C_=KG!9-wL6SYQY%C)urCqSU4l{C?S!s zxVT8^<_LpN*7y#vPcJ@8jE|@MJgghB7b;^pffrPrS;KXgSmoAx)x@N}J@?!`%GnaE zBD!D|+1p)TXe*hM(_lH{rGiKla@(~#Z^EaR$9e{M)6mRjGnsG#wRVT6M_HwYvLV$# z0anCw^#Ygl1JhLAJ`noQadAN}<7^M}37yH$eQk#;?GwO@RA5hg@6wjh;7?6Qz5L6% zZp~p|bk6L4ZLe_jy=ttQc~Nye-p(_9cWYm@^stdzvVO28r)^=6a09vrj#mbt*8+@1rfxx#sp@H}%1YEg7m|A#8q1V;b)wtXN zlAcH+9v_CUNF}Lnul-(f4*KzZyy=cII21WD{xnz~{`9HCURt%dq~zE7uU}pYF8Xl4 zkP!7Z?TN=M-$nuv8Yt8>r`pz?Tel_y3im-`3Jwfh_J#r{bxeHhU0oJw5fM#vO)m|% zSY@z=AC*XWx2l- z_i^0VtE&^MfzyW>--NDt$=tTE{sa5x(K$Iez>kp^7Hj9*X2Q$kgN$J0_KWcG`||G9E}K^1>SiIb73Z_fsvI3eKso9F)bhLkBZ6GI)K_ zbamN8A~7Jons3N9NAud;Q?NdKRaRA0aFON0Y6jUCq zT$a_O^yRAeh>D7Ox~iEdDC{XH1e3su`mE>OJ26&Mo8KK-x4?d{8fG(xEQ2#1c5JuP zDVvRpoY2KiXBj&}efOfREw_m^0H@lkU_t6H)t8tlMFCyiiBpzPv?3LlAITVqo3Atq z%&BIQquk`UFbWBe;}8h)*IZY{tWF`~@>9ASZwqC>@kxn+u$k_OK8fygDV2yp#;%ez6h`(AIZ_=`McD@5I zz9t>@YItLo`eEs`WaZ}|FDR;Z*sKoCUO$+f@K89!VRQZY6OFO{t}C1g*Y;Ry@w0uc z_$xsn(=z&IZ~&m)+S_WYSte*TPDhSuVBb&#uTqh0!X7*A+O5JP=w)!s5ZkT|kqzk0 z8H`0TAwL#(sDHl{^IyfP*0F~Lf@Jy%L|(tamSe2|zw~?PM*Jt5kak{6qH#3xN2Z;z z3X^%x&QebmywacH{QD*$2R!quhe9PgImRam`~Zo2%eg(k^^+a#y~Z@5E+oJGU6yLx zQsFNMMC5OB)owj@cC}+Xs4czx zpAq*35l4(z3VTWV=n|>AxsuwOQuRp5PHo#6Ca4#yvddKQFHDZrsWGo~aYTh?7*6Qz zae~~#qL1W-_Ib5M{@8=n0u?j^z8TpzrVT;JMto%o8t5Gvcl zojze%vaUtHp6KU&>@TaQd^;EHd~}9E~}eE4LeSxxK?M5gmTFH z;#EwH6N0$Aa0-#7Zt6d}A9cr;MF#DiTRg{^Dx#(}sC?_-*6NSijN_GPB62ZS*IYmx z@v%g6qD!lu6rV1-*+EwCN2llpFC8hF)i-A)e8OgX@-Wb;W{-|pTVF)ufdpoC0pXmE z4ikHuKX0n$6~2Z;|FW`IM^Ty+WF(YcCAnWRR>qBWO5vEI%thQ~;4hC@qtANY*##;H+|F(!ivfs|RC@7-q zgeno16-L(9QKpt#Fb9ViJ4`TCb1wDkvm;MuO_mh7PeAGg%^2v`da)&h!2q2KNroO(v}ami`0-KY(w&tuJ3f5 zFu%{wMCH>@71tM-Da^#n44TP65<(7widWEdsZeuS>{8CZzoy~;g`iMfz)!9X#0=7< z3T58Ec~)kr@`F%8C+fC#I&0}gc9}^d%cC)F1)k~PG{!m2@M|jlzsw*MfD@@VEB4_> zIAmphskt{p7lwoDZcTqMysB^B9%F&OUnyHAn0X&#c&d%x{Y!^>x4s$2(PfEqVh&K# zcu6}F=hsA3M}*x$mV(lBgR${DN8xHgW*!1S{72bY9LQU0%hti)J!2gG;UaThxP_9=wHF)v!+& zHanVG=RM__s7wW~Ia?5#IrBc6Eq{MTQGo>a``p+eWs+KBWf>fP= z$~7RI!r%WST4>K(1{ZDAp|YrlRjAu3|3&*_Q+~z?%E|g!S+NAep+aG=zh`ha0;xr{yHXMdQt73Z5JoX1TUH$2)^*m@&EYn zv4dob`g1eC`iv<3mb*!WKnBG-2{*J(T(-64J}%?G0F}ltRx2o;Vpa2+P7;DMYx<-< zi_65=fEX+7ITbHik8SJdxOl8YOHhCY`(K8|gBN%yba)pv#og5gf*V!{{`GWVU)tAb z)Lk3)f*U;#L~hwm^G|3<$Y8T8k%fv!lq)oCN2z{453CX@MYFr&vlS(k6?D9MTjwRc z_VxXv3vbF@AznW#*d_gj>8O9$)B-loo=^GL&}qYD`y*=l$*t_JdD*Qg_XFjXZ! ({})); + throw new Error(err.detail || `Failed to fetch ${url}: ${resp.status}`); + } + return resp.arrayBuffer(); +} + +async function postForBinary(url, body) { + const resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `Request failed: ${resp.status}`); + } + return resp.arrayBuffer(); +} + +// ─── Shared sub-components ──────────────────────────────────────────────────── + +function StepIndicator({ current }) { + return ( +
+ {STEPS.map((step, i) => { + const done = step.id < current; + const active = step.id === current; + const pending = step.id > current; + const isLast = i === STEPS.length - 1; + + return ( +
+ {/* Circle */} +
+
+ {done ? ( + + + + ) : ( + step.id + )} +
+ + {step.label} + +
+ + {/* Connector line */} + {!isLast && ( +
+ )} +
+ ); + })} +
+ ); +} + +function ProgressBar({ label, percent }) { + return ( +
+
+ {label} + {Math.round(percent)}% +
+
+
+
+
+ ); +} + +function InfoBox({ type = "info", children }) { + const styles = { + info: { bg: "var(--badge-blue-bg)", border: "#1e3a5f", color: "var(--badge-blue-text)" }, + warning: { bg: "#2e1a00", border: "#7c4a00", color: "#fb923c" }, + error: { bg: "var(--danger-bg)", border: "var(--danger)", color: "var(--danger-text)" }, + success: { bg: "var(--success-bg)", border: "var(--success)", color: "var(--success-text)" }, + }; + const s = styles[type] || styles.info; + return ( +
+ {children} +
+ ); +} + +function SectionTitle({ children }) { + return ( +

+ {children} +

+ ); +} + +function SubTitle({ children }) { + return ( +

+ {children} +

+ ); +} + +function PrimaryButton({ onClick, disabled, children, fullWidth = false }) { + return ( + + ); +} + +function SecondaryButton({ onClick, disabled, children }) { + return ( + + ); +} + +// ─── Step 1: Welcome ────────────────────────────────────────────────────────── + +function StepWelcome({ onNext }) { + return ( +
+ +

+ Welcome to BellSystems, CloudFlash ! +

+

+ CloudFlash allows you to restore or update the firmware on your BellSystems device

using only + a USB cable and your web browser — no special software required. +

+ +
+

+ Before you begin, please make sure: +

+ {[ + "You are using Google Chrome or Microsoft Edge on a desktop or laptop computer.", + "Your BellSystems device is connected via USB cable.", + "The device is powered on.", + "You have the serial number from the sticker on the bottom of your device (needed for a full restore only).", + ].map((item, i) => ( +
+ + {item} +
+ ))} +
+ + + I'm ready — let's begin → + +
+ ); +} + +// ─── Step 2: Select Hardware ────────────────────────────────────────────────── + +function StepSelectHardware({ onNext }) { + const [firmwares, setFirmwares] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [selectedHwType, setHwType] = useState(""); + + useEffect(() => { + fetch("/api/public/cloudflash/firmware") + .then((r) => { + if (!r.ok) return r.json().then((e) => { throw new Error(e.detail || "Failed to load firmware list."); }); + return r.json(); + }) + .then(setFirmwares) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + const selectedFw = firmwares.find((f) => f.hw_type === selectedHwType); + + return ( +
+ Select Your Device Type + + Choose the model that matches your device.

You can find the model name on the white sticker + on the bottom or back panel of your device, labelled "Model". +
+ + {error &&
{error}
} + + {loading ? ( +
+

Loading available devices…

+
+ ) : firmwares.length === 0 ? ( +
+ + No firmware is currently available. Please contact BellSystems support. + +
+ ) : ( +
+ {firmwares.map((fw) => ( + setHwType(fw.hw_type)} + /> + ))} +
+ )} + +
+ + + +

+ Not sure which model you have? The model name is printed on the label on the back of your device. + If you still can't find it, contact BellSystems support before proceeding. +

+
+ +
+ onNext(selectedFw)} disabled={!selectedFw}> + Continue with {selectedFw ? selectedFw.hw_type_label : "selected device"} → + +
+
+ ); +} + +function HardwareCard({ fw, selected, onClick }) { + return ( + + ); +} + +// ─── Step 3: Flash Type ─────────────────────────────────────────────────────── + +function StepSelectFlashType({ firmware, onNext }) { + const [flashType, setFlashType] = useState(""); + const [serial, setSerial] = useState(""); + const [serialError, setSerialError] = useState(""); + const [serialValid, setSerialValid] = useState(false); + const [validating, setValidating] = useState(false); + + const handleSerialBlur = async () => { + const trimmed = serial.trim().toUpperCase(); + if (!trimmed) return; + setValidating(true); + setSerialError(""); + setSerialValid(false); + try { + const resp = await fetch(`/api/public/cloudflash/validate-serial/${encodeURIComponent(trimmed)}`); + const data = await resp.json(); + if (data.valid) { + setSerialValid(true); + } else { + setSerialError( + "We couldn't find this serial number in our database. " + + "Please double-check the sticker on your device and try again. " + + "If you're sure it's correct, please contact BellSystems support." + ); + } + } catch { + setSerialError("Could not verify serial number. Please check your internet connection and try again."); + } finally { + setValidating(false); + } + }; + + const handleContinue = () => { + if (flashType === FLASH_TYPE_FULL) { + const trimmed = serial.trim().toUpperCase(); + if (!trimmed) { setSerialError("Please enter the serial number from the sticker on your device."); return; } + if (!serialValid) { setSerialError("Please wait for the serial number to be verified, or correct it first."); return; } + onNext({ flashType, serial: trimmed }); + } else { + onNext({ flashType, serial: null }); + } + }; + + const isFullWipe = flashType === FLASH_TYPE_FULL; + const inputBorderColor = serialError ? "var(--danger)" : serialValid ? "var(--accent)" : "var(--border-input)"; + + return ( +
+ Choose Flash Type + + Select how you'd like to restore your {firmware.hw_type_label}. + If you're unsure, start with Firmware Only. + + +
+ + + + } + selected={flashType === FLASH_TYPE_FW_ONLY} + onClick={() => setFlashType(FLASH_TYPE_FW_ONLY)} + /> + + + + } + selected={flashType === FLASH_TYPE_FULL} + onClick={() => setFlashType(FLASH_TYPE_FULL)} + /> +
+ + {/* Serial number field — only shown for full wipe */} + {isFullWipe && ( +
+ +

+ Your serial number is printed on the sticker on the bottom of your device.

+ It looks something like: BSVSPR-28F17R-PRO10R-4UAQPF. +

Enter it exactly as shown, then click outside the box to verify. +

+
+ { setSerial(e.target.value.toUpperCase()); setSerialError(""); setSerialValid(false); }} + onBlur={handleSerialBlur} + placeholder="BSXXXX-XXXXXX-XXXXXX-XXXXXX" + className="w-full px-3 py-2.5 rounded-md text-sm border font-mono pr-10" + style={{ backgroundColor: "var(--bg-input)", borderColor: inputBorderColor, color: "var(--text-primary)" }} + spellCheck={false} + disabled={validating} + /> +
+ {validating && ( + + + + + )} + {!validating && serialValid && ( + + + + )} + {!validating && serialError && ( + + + + )} +
+
+ {serialError && ( +

{serialError}

+ )} + {serialValid && ( +

+ ✓ Serial number verified — device found in the BellSystems database. +

+ )} +
+ )} + +
+ + {validating ? "Verifying serial…" : "Continue →"} + +
+
+ ); +} + +function FlashTypeCard({ title, subtitle, description, badge, badgeColor, badgeBg, icon, selected, onClick }) { + return ( + + ); +} + +// ─── Step 4: Connect & Flash ────────────────────────────────────────────────── + +function StepFlash({ firmware, flashType, serial, onDone }) { + const [phase, setPhase] = useState("connect"); // connect | flashing | done + const [connecting, setConnecting] = useState(false); + const [portName, setPortName] = useState(""); + const [portConnected, setPortConnected] = useState(false); + const [blPct, setBlPct] = useState(0); + const [partPct, setPartPct] = useState(0); + const [nvsPct, setNvsPct] = useState(0); + const [fwPct, setFwPct] = useState(0); + const [error, setError] = useState(""); + const [log, setLog] = useState([]); + + const portRef = useRef(null); + const loaderRef = useRef(null); + const logEndRef = useRef(null); + + const appendLog = (msg) => { + setLog((prev) => [...prev, String(msg)]); + setTimeout(() => logEndRef.current?.scrollIntoView({ behavior: "smooth" }), 50); + }; + + const webSerialAvailable = "serial" in navigator; + + const handleConnect = async () => { + setError(""); + setConnecting(true); + try { + const port = await navigator.serial.requestPort(); + const info = port.getInfo?.() || {}; + const label = info.usbVendorId + ? `USB ${info.usbVendorId.toString(16).toUpperCase()}:${(info.usbProductId || 0).toString(16).toUpperCase()}` + : "Serial Port"; + portRef.current = port; + setPortName(label); + setPortConnected(true); + } catch (err) { + if (err.name !== "NotFoundError") { + setError(err.message || "Port selection failed."); + } + } finally { + setConnecting(false); + } + }; + + const handleFlash = async () => { + if (!portRef.current) return; + setError(""); + setLog([]); + setBlPct(0); setPartPct(0); setNvsPct(0); setFwPct(0); + setPhase("flashing"); + + try { + appendLog("Fetching firmware files from BellSystems servers…"); + + let blBuffer = null, partBuffer = null, nvsBuffer = null; + + if (flashType === FLASH_TYPE_FULL) { + appendLog("Downloading bootloader…"); + blBuffer = await fetchBinary(`/api/public/cloudflash/${firmware.hw_type}/bootloader.bin`); + appendLog(`Bootloader: ${blBuffer.byteLength} bytes`); + + appendLog("Downloading partition table…"); + partBuffer = await fetchBinary(`/api/public/cloudflash/${firmware.hw_type}/partitions.bin`); + appendLog(`Partition table: ${partBuffer.byteLength} bytes`); + + appendLog("Generating NVS identity data…"); + nvsBuffer = await postForBinary("/api/public/cloudflash/nvs.bin", { + serial_number: serial, + hw_type: firmware.hw_type, + hw_revision: "1.0", + }); + appendLog(`NVS: ${nvsBuffer.byteLength} bytes`); + } + + appendLog("Downloading firmware…"); + const fwBuffer = await fetchBinary(firmware.download_url); + appendLog(`Firmware: ${fwBuffer.byteLength} bytes`); + + appendLog("Connecting to your device…"); + const transport = new Transport(portRef.current, true); + loaderRef.current = new ESPLoader({ + transport, + baudrate: FLASH_BAUD, + terminal: { + clean() {}, + writeLine: (line) => { appendLog(line); }, + write: (msg) => { appendLog(msg); }, + }, + }); + + await loaderRef.current.main(); + appendLog("Device connected. Starting flash…"); + + const fileArray = flashType === FLASH_TYPE_FULL + ? [ + { data: arrayBufferToString(blBuffer), address: 0x1000 }, + { data: arrayBufferToString(partBuffer), address: 0x8000 }, + { data: arrayBufferToString(nvsBuffer), address: NVS_ADDRESS }, + { data: arrayBufferToString(fwBuffer), address: FW_ADDRESS }, + ] + : [ + { data: arrayBufferToString(fwBuffer), address: FW_ADDRESS }, + ]; + + await loaderRef.current.writeFlash({ + fileArray, + flashSize: "keep", flashMode: "keep", flashFreq: "keep", + eraseAll: false, compress: true, + reportProgress(fileIndex, written, total) { + const pct = (written / total) * 100; + if (flashType === FLASH_TYPE_FULL) { + if (fileIndex === 0) { setBlPct(pct); } + else if (fileIndex === 1) { setBlPct(100); setPartPct(pct); } + else if (fileIndex === 2) { setPartPct(100); setNvsPct(pct); } + else { setNvsPct(100); setFwPct(pct); } + } else { + setFwPct(pct); + } + }, + calculateMD5Hash: () => "", + }); + + if (flashType === FLASH_TYPE_FULL) { + setBlPct(100); setPartPct(100); setNvsPct(100); + } + setFwPct(100); + appendLog("Flash complete! Rebooting device…"); + + // Hard reset via RTS + try { + const t = loaderRef.current.transport; + await t.setRTS(true); + await new Promise((r) => setTimeout(r, 100)); + await t.setRTS(false); + } catch (_) {} + + try { await loaderRef.current.transport.disconnect(); } catch (_) {} + + setPhase("done"); + onDone({ serial }); + } catch (err) { + setError(err.message || String(err)); + setPhase("connect"); + } + }; + + const isFullWipe = flashType === FLASH_TYPE_FULL; + const flashing = phase === "flashing"; + + return ( +
+ Connect & Flash + + Connect your {firmware.hw_type_label} via USB, + then click "Select Port" to choose it. + {isFullWipe && serial && ( + <> Your serial number {serial} will be written to the device. + )} + + + {!webSerialAvailable && ( +
+ + Browser not supported. CloudFlash requires Google Chrome or Microsoft Edge on a desktop + computer. Safari, Firefox, and mobile browsers are not supported. + +
+ )} + + {error && ( +
+ + Error: {error} + +
+ )} + + {/* Port status */} +
+
+ + + {portConnected ? portName || "Device connected" : "No device selected"} + +
+ {!portConnected && ( + + )} +
+ + {/* How-to hint */} + {!portConnected && !flashing && ( +
+

How to connect your device:

+ {[ + "Plug your BellSystems device into your computer using a USB cable.", + "Click the \"Select Port\" button above and a browser popup will appear.", + "Look for an entry that says \"CP210x\", \"CH340\" or \"USB Serial (COM#)\" and click it.", + "If you don't see your device, try a different USB cable or port.", + ].map((step, i) => ( +
+ {i + 1}. + {step} +
+ ))} +
+ )} + + {/* Progress bars */} + {(flashing || fwPct > 0) && ( +
+ {isFullWipe && ( + <> + + + + + )} + +
+ )} + + {flashing && ( +
+ + Please do not disconnect your device or close this tab while flashing is in progress. + This could permanently damage your device. + +
+ )} + + {/* Flash log */} + {log.length > 0 && ( +
+ {log.map((line, i) =>
{line}
)} +
+
+ )} + +
+ + {flashing ? "Flashing… please wait" : "Start Flash →"} + +
+
+ ); +} + +// ─── Step 5: Done ───────────────────────────────────────────────────────────── + +function StepDone({ firmware, flashType, serial, onReset }) { + const [verifyStatus, setVerifyStatus] = useState("waiting"); // waiting | online | timeout + const isFullWipe = flashType === FLASH_TYPE_FULL; + + useEffect(() => { + if (!isFullWipe || !serial) { setVerifyStatus("skipped"); return; } + + let elapsed = 0; + const interval = setInterval(async () => { + elapsed += VERIFY_POLL_MS; + if (elapsed > VERIFY_TIMEOUT_MS) { + clearInterval(interval); + setVerifyStatus("timeout"); + return; + } + try { + // We poll the public status endpoint — the device heartbeat will show if it's online + const resp = await fetch(`/api/public/cloudflash/status`); + // We can't directly check MQTT status without auth, so we just wait for the + // device to appear via a public heartbeat check. For now we show a friendly + // "waiting" message and let it time out gracefully. + // If you have a public device-online endpoint, swap it in here. + } catch (_) {} + }, VERIFY_POLL_MS); + + return () => clearInterval(interval); + }, [isFullWipe, serial]); + + return ( +
+ {/* Success icon */} +
+ + + +
+ +

+ Flash Complete! +

+

+ Your {firmware.hw_type_label} has been + successfully {flashType === FLASH_TYPE_FULL ? "restored to factory settings" : "updated"}. + The device is now rebooting. +

+ + {isFullWipe && ( +
+ {verifyStatus === "waiting" && ( +
+ + + Waiting for device to connect to BellCloud… + +
+ )} + {verifyStatus === "timeout" && ( + + We couldn't confirm your device connected automatically. + This is normal — it may take a few minutes for the device to join the network. + You can close this page. + + )} + {verifyStatus === "skipped" && null} +
+ )} + +
+

+ What to do next +

+ {[ + "You can safely disconnect the USB cable.", + "Power cycle the device if it doesn't restart automatically.", + "The device will reconnect to your network and BellCloud automatically.", + "If you experience any issues, please contact BellSystems support.", + ].map((item, i) => ( +
+ {i + 1}. + {item} +
+ ))} +
+ + + Flash another device + +
+ ); +} + +// ─── Disabled / Feature Gate Screen ────────────────────────────────────────── + +function DisabledScreen() { + return ( +
+
+
+ + + +
+

+ CloudFlash is Currently Unavailable +

+

+ The CloudFlash service is temporarily disabled. Please check back later or contact + BellSystems support if you need urgent assistance with your device. +

+
+
+ ); +} + +// ─── Main CloudFlash Page ───────────────────────────────────────────────────── + +export default function CloudFlashPage() { + const [gateLoading, setGateLoading] = useState(true); + const [enabled, setEnabled] = useState(false); + + // Wizard state + const [step, setStep] = useState(1); + const [firmware, setFirmware] = useState(null); // { hw_type, hw_type_label, channel, version, download_url } + const [flashType, setFlashType] = useState(null); // FLASH_TYPE_FULL | FLASH_TYPE_FW_ONLY + const [serial, setSerial] = useState(null); // user-provided serial (full wipe only) + + // Check feature gate + useEffect(() => { + fetch("/api/public/cloudflash/status") + .then((r) => r.json()) + .then((data) => setEnabled(data.enabled ?? false)) + .catch(() => setEnabled(false)) + .finally(() => setGateLoading(false)); + }, []); + + const reset = () => { + setStep(1); + setFirmware(null); + setFlashType(null); + setSerial(null); + }; + + if (gateLoading) { + return ( +
+

Loading…

+
+ ); + } + + if (!enabled) return ; + + return ( +
+ {/* Brand header */} +
+ CloudFlash +
+ + {/* Step indicator */} +
+ +
+ + {/* Wizard card */} +
+ {step === 1 && ( + setStep(2)} /> + )} + {step === 2 && ( + { setFirmware(fw); setStep(3); }} + /> + )} + {step === 3 && firmware && ( + { + setFlashType(ft); + setSerial(sn); + setStep(4); + }} + /> + )} + {step === 4 && firmware && flashType && ( + setStep(5)} + /> + )} + {step === 5 && firmware && flashType && ( + + )} +
+ + {/* Footer */} +

+ © {new Date().getFullYear()} BellSystems · CloudFlash v1.0 +

+
+ ); +} diff --git a/frontend/src/crm/customers/CustomerDetail.jsx b/frontend/src/crm/customers/CustomerDetail.jsx index 09a817d..65e947a 100644 --- a/frontend/src/crm/customers/CustomerDetail.jsx +++ b/frontend/src/crm/customers/CustomerDetail.jsx @@ -1188,7 +1188,7 @@ export default function CustomerDetail() {
{customer.notes.map((note, i) => (
-

{note.text}

+

{note.text}

{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}

diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx index 0adb59a..adff671 100644 --- a/frontend/src/devices/DeviceDetail.jsx +++ b/frontend/src/devices/DeviceDetail.jsx @@ -1556,6 +1556,7 @@ const TAB_DEFS = [ { id: "bells", label: "Bell Mechanisms", tone: "bells" }, { id: "clock", label: "Clock & Alerts", tone: "clock" }, { id: "warranty", label: "Warranty & Subscription", tone: "warranty" }, + { id: "manage", label: "Manage", tone: "manage" }, { id: "control", label: "Control", tone: "control" }, ]; @@ -1574,6 +1575,104 @@ function calcMaintenanceProgress(lastDate, periodDays) { return Math.max(0, Math.min(100, (elapsed / total) * 100)); } +// ─── Customer Assign Modal ──────────────────────────────────────────────────── +function CustomerAssignModal({ deviceId, onSelect, onCancel }) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { inputRef.current?.focus(); }, []); + + const search = useCallback(async (q) => { + setSearching(true); + try { + const data = await api.get(`/devices/${deviceId}/customer-search?q=${encodeURIComponent(q)}`); + setResults(data.results || []); + } catch { + setResults([]); + } finally { + setSearching(false); + } + }, [deviceId]); + + useEffect(() => { + const t = setTimeout(() => search(query), 250); + return () => clearTimeout(t); + }, [query, search]); + + return ( +
+
+
+

Assign to Customer

+ +
+
+
+ setQuery(e.target.value)} + placeholder="Search by name, email, phone, org, tags…" + className="w-full px-3 py-2 rounded-md text-sm border" + style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }} + /> + {searching && ( + + )} +
+
+
+
+ {results.length === 0 ? ( +

+ {searching ? "Searching…" : query ? "No customers found." : "Type to search customers…"} +

+ ) : ( + results.map((c) => ( + + )) + )} +
+
+
+ +
+
+
+ ); +} + export default function DeviceDetail() { const { id } = useParams(); const navigate = useNavigate(); @@ -1630,18 +1729,29 @@ export default function DeviceDetail() { const d = await api.get(`/devices/${id}`); setDevice(d); if (d.staffNotes) setStaffNotes(d.staffNotes); + if (Array.isArray(d.tags)) setTags(d.tags); setLoading(false); // Phase 2: fire async background fetches — do not block the render - if (d.device_id) { + const deviceSN = d.serial_number || d.device_id; + if (deviceSN) { api.get("/mqtt/status").then((mqttData) => { if (mqttData?.devices) { - const match = mqttData.devices.find((s) => s.device_serial === d.device_id); + const match = mqttData.devices.find((s) => s.device_serial === deviceSN); setMqttStatus(match || null); } }).catch(() => {}); } + // Fetch owner customer details + if (d.customer_id) { + api.get(`/devices/${id}/customer`).then((res) => { + setOwnerCustomer(res.customer || null); + }).catch(() => setOwnerCustomer(null)); + } else { + setOwnerCustomer(null); + } + setUsersLoading(true); api.get(`/devices/${id}/users`).then((data) => { setDeviceUsers(data.users || []); @@ -1650,9 +1760,9 @@ export default function DeviceDetail() { }).finally(() => setUsersLoading(false)); // Fetch manufacturing record + product catalog to resolve hw image - if (d.device_id) { + if (deviceSN) { Promise.all([ - api.get(`/manufacturing/devices/${d.device_id}`).catch(() => null), + api.get(`/manufacturing/devices/${deviceSN}`).catch(() => null), api.get("/crm/products").catch(() => null), ]).then(([mfgItem, productsRes]) => { const hwType = mfgItem?.hw_type || ""; @@ -1719,6 +1829,85 @@ export default function DeviceDetail() { } }; + // --- Device Notes handlers --- + const handleAddNote = async () => { + if (!newNoteText.trim()) return; + setSavingNote(true); + try { + const data = await api.post(`/devices/${id}/notes`, { + content: newNoteText.trim(), + created_by: "admin", + }); + setDeviceNotes((prev) => [data, ...prev]); + setNewNoteText(""); + setAddingNote(false); + } catch {} finally { setSavingNote(false); } + }; + + const handleUpdateNote = async (noteId) => { + if (!editingNoteText.trim()) return; + setSavingNote(true); + try { + const data = await api.put(`/devices/${id}/notes/${noteId}`, { content: editingNoteText.trim() }); + setDeviceNotes((prev) => prev.map((n) => n.id === noteId ? data : n)); + setEditingNoteId(null); + } catch {} finally { setSavingNote(false); } + }; + + const handleDeleteNote = async (noteId) => { + if (!window.confirm("Delete this note?")) return; + try { + await api.delete(`/devices/${id}/notes/${noteId}`); + setDeviceNotes((prev) => prev.filter((n) => n.id !== noteId)); + } catch {} + }; + + // --- Tags handlers --- + const handleAddTag = async (tag) => { + const trimmed = tag.trim(); + if (!trimmed || tags.includes(trimmed)) return; + const next = [...tags, trimmed]; + setSavingTags(true); + try { + await api.put(`/devices/${id}/tags`, { tags: next }); + setTags(next); + setTagInput(""); + } catch {} finally { setSavingTags(false); } + }; + + const handleRemoveTag = async (tag) => { + const next = tags.filter((t) => t !== tag); + setSavingTags(true); + try { + await api.put(`/devices/${id}/tags`, { tags: next }); + setTags(next); + } catch {} finally { setSavingTags(false); } + }; + + // --- Customer assign handlers --- + const handleAssignCustomer = async (customer) => { + setAssigningCustomer(true); + try { + await api.post(`/devices/${id}/assign-customer`, { customer_id: customer.id }); + setDevice((prev) => ({ ...prev, customer_id: customer.id })); + setOwnerCustomer(customer); + setShowAssignSearch(false); + setCustomerSearch(""); + setCustomerResults([]); + } catch {} finally { setAssigningCustomer(false); } + }; + + const handleUnassignCustomer = async () => { + if (!window.confirm("Remove customer assignment?")) return; + setAssigningCustomer(true); + try { + const cid = device?.customer_id; + await api.delete(`/devices/${id}/assign-customer${cid ? `?customer_id=${cid}` : ""}`); + setDevice((prev) => ({ ...prev, customer_id: "" })); + setOwnerCustomer(null); + } catch {} finally { setAssigningCustomer(false); } + }; + const requestStrikeCounters = useCallback(async (force = false) => { if (!device?.device_id) return; const now = Date.now(); @@ -1800,6 +1989,66 @@ export default function DeviceDetail() { return () => clearInterval(interval); }, [ctrlCmdAutoRefresh, device?.device_id, fetchCtrlCmdHistory]); + // --- Device Notes state (MUST be before early returns) --- + const [deviceNotes, setDeviceNotes] = useState([]); + const [notesLoaded, setNotesLoaded] = useState(false); + const [addingNote, setAddingNote] = useState(false); + const [newNoteText, setNewNoteText] = useState(""); + const [savingNote, setSavingNote] = useState(false); + const [editingNoteId, setEditingNoteId] = useState(null); + const [editingNoteText, setEditingNoteText] = useState(""); + + const loadDeviceNotes = useCallback(async () => { + try { + const data = await api.get(`/devices/${id}/notes`); + setDeviceNotes(data.notes || []); + setNotesLoaded(true); + } catch { + setNotesLoaded(true); + } + }, [id]); + + useEffect(() => { + if (id) loadDeviceNotes(); + }, [id, loadDeviceNotes]); + + // --- Tags state (MUST be before early returns) --- + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(""); + const [savingTags, setSavingTags] = useState(false); + + // --- Customer assign state (MUST be before early returns) --- + const [assigningCustomer, setAssigningCustomer] = useState(false); + const [showAssignSearch, setShowAssignSearch] = useState(false); + const [ownerCustomer, setOwnerCustomer] = useState(null); + + // --- User assignment state (MUST be before early returns) --- + const [showUserSearch, setShowUserSearch] = useState(false); + const [userSearchQuery, setUserSearchQuery] = useState(""); + const [userSearchResults, setUserSearchResults] = useState([]); + const [userSearching, setUserSearching] = useState(false); + const [addingUser, setAddingUser] = useState(null); + const [removingUser, setRemovingUser] = useState(null); + const userSearchInputRef = useRef(null); + + const searchUsers = useCallback(async (q) => { + setUserSearching(true); + try { + const data = await api.get(`/devices/${id}/user-search?q=${encodeURIComponent(q)}`); + setUserSearchResults(data.results || []); + } catch { + setUserSearchResults([]); + } finally { + setUserSearching(false); + } + }, [id]); + + useEffect(() => { + if (!showUserSearch) return; + const t = setTimeout(() => searchUsers(userSearchQuery), 250); + return () => clearTimeout(t); + }, [userSearchQuery, searchUsers, showUserSearch]); + if (loading) return
Loading...
; if (error) return (
@@ -1952,7 +2201,7 @@ export default function DeviceDetail() {
SERIAL NUMBER - {device.device_id || "-"} + {device.serial_number || device.device_id || "-"}
@@ -2115,7 +2364,7 @@ export default function DeviceDetail() {
- +
@@ -2253,6 +2502,260 @@ export default function DeviceDetail() { + + {/* ── Tags ── */} + +
+ {tags.length === 0 && ( + No tags yet. + )} + {tags.map((tag) => ( + + {tag} + {canEdit && ( + + )} + + ))} +
+ {canEdit && ( +
+ setTagInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddTag(tagInput); } }} + placeholder="Add tag and press Enter…" + className="px-3 py-1.5 rounded-md text-sm border flex-1" + style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }} + /> + +
+ )} +
+ + {/* ── Owner ── */} + + {device.customer_id ? ( +
+ {ownerCustomer ? ( +
navigate(`/crm/customers/${device.customer_id}`)} + title="View customer" + > +
+ {(ownerCustomer.name || "?")[0].toUpperCase()} +
+
+

{ownerCustomer.name || "—"}

+ {ownerCustomer.organization && ( +

{ownerCustomer.organization}

+ )} +
+ + + +
+ ) : ( +

Customer assigned (loading details…)

+ )} + {canEdit && ( +
+ + +
+ )} +
+ ) : ( +
+

No customer assigned yet.

+ {canEdit && ( + + )} +
+ )} + + {showAssignSearch && ( + { setShowAssignSearch(false); handleAssignCustomer(c); }} + onCancel={() => setShowAssignSearch(false)} + /> + )} +
+ + {/* ── Device Notes ── */} + + {!notesLoaded ? ( +

Loading…

+ ) : ( + <> + {deviceNotes.length === 0 && !addingNote && ( +

No notes for this device.

+ )} +
+ {deviceNotes.map((note) => ( +
+ {editingNoteId === note.id ? ( +
+