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>
380 lines
12 KiB
Python
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
|