fix(devices): sync users.device_serials when PUT /api/devices/{id} changes user_list
DeviceUpdate accepts user_list, so a device PUT could add or remove users without touching their device_serials - leaving the MQTT app ACL stale (a removed user would keep access; an added user would be denied). update_device now diffs the old vs new user_list and, in the same atomic batch as the device write, ArrayUnion/ArrayRemoves the device's serial on each added/removed user, then invalidates their MQTT ACL cache entries. Dangling user references are skipped (updating a missing doc would fail the whole batch). PUTs without user_list take the old single-update path. Covered by tests/test_device_serials_sync.py (fake Firestore). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -230,12 +230,45 @@ def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB:
|
||||
update_data[key] = _deep_merge(existing[key], update_data[key])
|
||||
|
||||
update_data = _restore_timestamps(update_data)
|
||||
doc_ref.update(update_data)
|
||||
|
||||
if "user_list" not in update_data:
|
||||
doc_ref.update(update_data)
|
||||
else:
|
||||
# A user_list change must also update each affected user's
|
||||
# device_serials (MQTT ACL), in the same atomic batch.
|
||||
from users import service as users_service
|
||||
|
||||
old_ids = {_user_list_entry_id(e) for e in (existing.get("user_list") or [])} - {""}
|
||||
new_ids = {_user_list_entry_id(e) for e in update_data["user_list"]} - {""}
|
||||
serial = users_service.device_serial_of(existing)
|
||||
|
||||
batch = db.batch()
|
||||
batch.update(doc_ref, update_data)
|
||||
touched = []
|
||||
for user_id in (old_ids ^ new_ids):
|
||||
user_ref = db.collection("users").document(user_id)
|
||||
user_doc = user_ref.get()
|
||||
if not user_doc.exists:
|
||||
continue # dangling reference — nothing to sync
|
||||
users_service.stage_device_serial_link(batch, user_ref, serial, linked=user_id in new_ids)
|
||||
touched.append(user_doc.to_dict())
|
||||
batch.commit()
|
||||
for user_data in touched:
|
||||
users_service.invalidate_mqtt_acl_cache(user_data)
|
||||
|
||||
updated_doc = doc_ref.get()
|
||||
return _doc_to_device(updated_doc)
|
||||
|
||||
|
||||
def _user_list_entry_id(entry) -> str:
|
||||
"""user_list entries are DocumentReferences, "users/{id}" paths or raw doc IDs."""
|
||||
if isinstance(entry, DocumentReference):
|
||||
return entry.id
|
||||
if isinstance(entry, str):
|
||||
return entry.strip().split("/")[-1]
|
||||
return ""
|
||||
|
||||
|
||||
def get_device_users(device_doc_id: str) -> list[dict]:
|
||||
"""Get users assigned to a device from the device_users sub-collection.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user