Files
bellsystems-cp/backend/users/service.py
T
bonaminandClaude Opus 5.5 79a87e48f1 feat(users): store device_serials on user docs for direct user->device lookup
Adds a `device_serials: [string]` array to Firestore `users` docs so the
MQTT ACL (and get_user_devices) can answer "which boards may this user
reach?" without streaming the entire devices collection.

- The serial is the value used in MQTT topics vesper/{serial}/...: the
  device doc's `serial_number` (flashed into NVS, used by the firmware as
  its MQTT id), falling back to the legacy `device_id` for old docs.
  Centralised in users.service.device_serial_of().
- assign_device / unassign_device now write the device's user_list and the
  user's device_serials (ArrayUnion/ArrayRemove) in one atomic batch.
- The device Manage tab endpoints (POST/DELETE /api/devices/{id}/user-list)
  also edit user_list, so they get the same batched sync - otherwise the
  most common assignment path would silently leave device_serials stale.
- get_user_devices resolves devices via device_serials with chunked
  Firestore "in" queries instead of a full collection scan. Requires the
  backfill script (next commit) to be run for existing assignments.
- New mqtt/app_users.py: resolves users by the `uid` FIELD (not doc id -
  create_user uses .add(), FlutterFlow uses uid as doc id) with a 60s
  in-process TTL cache. Assign/unassign, update, block/unblock and delete
  invalidate that uid's entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:00:49 +03:00

380 lines
12 KiB
Python

from datetime import datetime
from google.cloud.firestore_v1 import DocumentReference, ArrayUnion, ArrayRemove
from firebase_admin import auth as firebase_auth
from shared.firebase import get_db, get_bucket
from shared.exceptions import NotFoundError, ValidationError
from users.models import UserCreate, UserUpdate, UserInDB
COLLECTION = "users"
# Firestore "in" queries accept at most 30 values.
_IN_QUERY_LIMIT = 30
def device_serial_of(device_data: dict) -> str:
"""The board serial as used in MQTT topics (vesper/{serial}/...).
`serial_number` is what gets flashed into NVS and what the firmware uses
as its MQTT username/topic id. Legacy docs predating the lifecycle system
only carry it in `device_id`.
"""
return (device_data.get("serial_number") or device_data.get("device_id") or "").strip()
def stage_device_serial_link(batch, user_ref: DocumentReference, serial: str, linked: bool) -> None:
"""Add (linked=True) or remove the serial from the user's `device_serials`
inside the caller's write batch, so it commits together with the device's
`user_list` change."""
if not serial:
return
op = ArrayUnion([serial]) if linked else ArrayRemove([serial])
batch.update(user_ref, {"device_serials": op})
def invalidate_mqtt_acl_cache(user_data: dict | None) -> None:
"""Drop the MQTT ACL cache entry for this user's Firebase uid."""
uid = (user_data or {}).get("uid") or ""
if uid:
from mqtt.app_users import invalidate
invalidate(uid)
def _convert_firestore_value(val):
"""Convert Firestore-specific types (Timestamp, DocumentReference) to strings."""
if isinstance(val, datetime):
return val.strftime("%d %B %Y at %H:%M:%S UTC%z")
if isinstance(val, DocumentReference):
return val.path
return val
def _sanitize_dict(d: dict) -> dict:
"""Recursively convert Firestore-native types in a dict to plain strings."""
result = {}
for k, v in d.items():
if isinstance(v, dict):
result[k] = _sanitize_dict(v)
elif isinstance(v, list):
result[k] = [
_sanitize_dict(item) if isinstance(item, dict)
else _convert_firestore_value(item)
for item in v
]
else:
result[k] = _convert_firestore_value(v)
return result
def _doc_to_user(doc) -> UserInDB:
"""Convert a Firestore document snapshot to a UserInDB model."""
data = _sanitize_dict(doc.to_dict())
return UserInDB(id=doc.id, **data)
def list_users(
search: str | None = None,
status: str | None = None,
) -> list[UserInDB]:
"""List users with optional filters."""
db = get_db()
ref = db.collection(COLLECTION)
query = ref
if status:
query = query.where("status", "==", status)
docs = query.stream()
results = []
for doc in docs:
user = _doc_to_user(doc)
if search:
search_lower = search.lower()
name_match = search_lower in (user.display_name or "").lower()
email_match = search_lower in (user.email or "").lower()
phone_match = search_lower in (user.phone_number or "").lower()
uid_match = search_lower in (user.uid or "").lower()
if not (name_match or email_match or phone_match or uid_match):
continue
results.append(user)
return results
def get_user(user_doc_id: str) -> UserInDB:
"""Get a single user by Firestore document ID."""
db = get_db()
doc = db.collection(COLLECTION).document(user_doc_id).get()
if not doc.exists:
raise NotFoundError("User")
return _doc_to_user(doc)
def create_user(data: UserCreate) -> UserInDB:
"""Create a new user: a Firebase Auth account (so they can log into the app
immediately) plus the matching Firestore profile document."""
if not data.password or len(data.password) < 6:
raise ValidationError("Password must be at least 6 characters.")
if not data.email:
raise ValidationError("Email is required.")
db = get_db()
doc_data = data.model_dump(exclude={"password"})
doc_data["friendsList"] = []
doc_data["friendsInvited"] = []
try:
firebase_user = firebase_auth.create_user(
email=data.email,
password=data.password,
display_name=data.display_name or None,
)
except firebase_auth.EmailAlreadyExistsError:
raise ValidationError(f"A user with email {data.email} already exists.")
doc_data["uid"] = firebase_user.uid
_, doc_ref = db.collection(COLLECTION).add(doc_data)
return UserInDB(id=doc_ref.id, **doc_data)
def update_user(user_doc_id: str, data: UserUpdate) -> UserInDB:
"""Update an existing user document. Only provided fields are updated."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
update_data = data.model_dump(exclude_none=True)
doc_ref.update(update_data)
invalidate_mqtt_acl_cache(doc.to_dict()) # status may have changed
updated_doc = doc_ref.get()
return _doc_to_user(updated_doc)
def delete_user(user_doc_id: str) -> None:
"""Delete a user document from Firestore, plus their Firebase Auth account if one exists."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
uid = doc.to_dict().get("uid", "")
if uid:
try:
firebase_auth.delete_user(uid)
except firebase_auth.UserNotFoundError:
pass
doc_ref.delete()
invalidate_mqtt_acl_cache(doc.to_dict())
def block_user(user_doc_id: str) -> UserInDB:
"""Block a user by setting their status to 'blocked'."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
doc_ref.update({"status": "blocked"})
invalidate_mqtt_acl_cache(doc.to_dict())
updated_doc = doc_ref.get()
return _doc_to_user(updated_doc)
def unblock_user(user_doc_id: str) -> UserInDB:
"""Unblock a user by setting their status to 'active'."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
doc_ref.update({"status": "active"})
invalidate_mqtt_acl_cache(doc.to_dict())
updated_doc = doc_ref.get()
return _doc_to_user(updated_doc)
def assign_device(user_doc_id: str, device_doc_id: str) -> UserInDB:
"""Assign a device to a user by adding user ref to device's user_list."""
db = get_db()
# Verify user exists
user_ref = db.collection(COLLECTION).document(user_doc_id)
user_doc = user_ref.get()
if not user_doc.exists:
raise NotFoundError("User")
# Verify device exists
device_ref = db.collection("devices").document(device_doc_id)
device_doc = device_ref.get()
if not device_doc.exists:
raise NotFoundError("Device")
# Add user path to device's user_list if not already there
device_data = device_doc.to_dict()
user_list = device_data.get("user_list", [])
user_path = f"users/{user_doc_id}"
# Check if already assigned (handle both string paths and DocumentReferences)
already_assigned = False
for entry in user_list:
if isinstance(entry, DocumentReference):
if entry.path == user_path:
already_assigned = True
break
elif entry == user_path:
already_assigned = True
break
# Device user_list and the user's device_serials are written in one atomic batch.
# device_serials is re-synced even when already assigned, to heal drift.
batch = db.batch()
if not already_assigned:
user_list.append(user_path)
batch.update(device_ref, {"user_list": user_list})
stage_device_serial_link(batch, user_ref, device_serial_of(device_data), linked=True)
batch.commit()
invalidate_mqtt_acl_cache(user_doc.to_dict())
return _doc_to_user(user_ref.get())
def unassign_device(user_doc_id: str, device_doc_id: str) -> UserInDB:
"""Remove a user from a device's user_list."""
db = get_db()
# Verify user exists
user_ref = db.collection(COLLECTION).document(user_doc_id)
user_doc = user_ref.get()
if not user_doc.exists:
raise NotFoundError("User")
# Verify device exists
device_ref = db.collection("devices").document(device_doc_id)
device_doc = device_ref.get()
if not device_doc.exists:
raise NotFoundError("Device")
# Remove user from device's user_list
device_data = device_doc.to_dict()
user_list = device_data.get("user_list", [])
user_path = f"users/{user_doc_id}"
new_list = []
for entry in user_list:
if isinstance(entry, DocumentReference):
if entry.path != user_path:
new_list.append(entry)
elif entry != user_path:
new_list.append(entry)
batch = db.batch()
batch.update(device_ref, {"user_list": new_list})
stage_device_serial_link(batch, user_ref, device_serial_of(device_data), linked=False)
batch.commit()
invalidate_mqtt_acl_cache(user_doc.to_dict())
return _doc_to_user(user_ref.get())
def get_user_devices(user_doc_id: str) -> list[dict]:
"""Get all devices assigned to a user, via the user's `device_serials`."""
db = get_db()
# Verify user exists
user_ref = db.collection(COLLECTION).document(user_doc_id)
user_doc = user_ref.get()
if not user_doc.exists:
raise NotFoundError("User")
serials = [s for s in dict.fromkeys(user_doc.to_dict().get("device_serials") or []) if s]
# Match on serial_number first; fall back to the legacy device_id field for the rest.
found: dict[str, object] = {}
for field in ("serial_number", "device_id"):
remaining = [s for s in serials if s not in found]
for i in range(0, len(remaining), _IN_QUERY_LIMIT):
chunk = remaining[i:i + _IN_QUERY_LIMIT]
for doc in db.collection("devices").where(field, "in", chunk).stream():
sn = doc.to_dict().get(field)
if sn not in found:
found[sn] = doc
devices = []
for sn in serials:
doc = found.get(sn)
if doc is None:
continue
data = doc.to_dict()
devices.append({
"id": doc.id,
"device_name": data.get("device_name", ""),
"device_id": data.get("device_id", ""),
"device_location": data.get("device_location", ""),
"is_Online": data.get("is_Online", False),
})
return devices
def set_password(user_doc_id: str, new_password: str) -> None:
"""Set a Firebase Auth password for a user via their Firestore document ID.
Requires the user document to have a non-empty `uid` field — populated
automatically for users who registered via the Flutter app.
"""
if not new_password or len(new_password) < 6:
raise ValidationError("Password must be at least 6 characters.")
db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
uid = doc.to_dict().get("uid", "")
if not uid:
raise ValidationError("This user has no Firebase Auth UID — they may not have signed up via the app yet.")
try:
firebase_auth.update_user(uid, password=new_password)
except Exception as e:
raise RuntimeError(f"Firebase Auth error: {e}")
def upload_photo(user_doc_id: str, file_bytes: bytes, filename: str, content_type: str) -> str:
"""Upload a profile photo to Firebase Storage and update the user's photo_url."""
db = get_db()
bucket = get_bucket()
if not bucket:
raise RuntimeError("Firebase Storage not initialized")
# Verify user exists
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
ext = filename.rsplit(".", 1)[-1] if "." in filename else "jpg"
storage_path = f"users/{user_doc_id}/uploads/profile.{ext}"
blob = bucket.blob(storage_path)
blob.upload_from_string(file_bytes, content_type=content_type)
blob.make_public()
photo_url = blob.public_url
doc_ref.update({"photo_url": photo_url})
return photo_url