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>
This commit is contained in:
2026-09-30 00:00:49 +03:00
co-authored by Claude Opus 5.5
parent 017911bece
commit 79a87e48f1
3 changed files with 180 additions and 25 deletions
+17 -3
View File
@@ -13,6 +13,7 @@ from devices.models import (
ResetStatsRequest, ResetStatsResult,
)
from devices import service
from users import service as users_service
import database as mqtt_db
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
from shared.firebase import get_db as get_firestore
@@ -461,10 +462,15 @@ async def add_user_to_device(
elif isinstance(entry, str):
existing_ids.add(entry.split("/")[-1])
# user_list and the user's device_serials (MQTT ACL lookup) commit together.
user_ref = fs.collection("users").document(body.user_id)
batch = fs.batch()
if body.user_id not in existing_ids:
user_ref = fs.collection("users").document(body.user_id)
user_list.append(user_ref)
device_ref.update({"user_list": user_list})
batch.update(device_ref, {"user_list": user_list})
users_service.stage_device_serial_link(batch, user_ref, users_service.device_serial_of(data), linked=True)
batch.commit()
users_service.invalidate_mqtt_acl_cache(user_doc.to_dict())
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
device_id, device_id, meta={"action_detail": "user_added",
@@ -500,7 +506,15 @@ async def remove_user_from_device(
# Remove any entry that resolves to this user_id (handles both DocRef and string paths)
new_list = [entry for entry in user_list if not resolves_to(entry, user_id)]
device_ref.update({"user_list": new_list})
batch = fs.batch()
batch.update(device_ref, {"user_list": new_list})
user_ref = fs.collection("users").document(user_id)
user_doc = user_ref.get()
if user_doc.exists:
users_service.stage_device_serial_link(batch, user_ref, users_service.device_serial_of(data), linked=False)
batch.commit()
if user_doc.exists:
users_service.invalidate_mqtt_acl_cache(user_doc.to_dict())
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
device_id, device_id, meta={"action_detail": "user_removed",