fix(users): console-created users use the Firebase uid as doc ID

create_user wrote the profile with .add() (random doc ID). On first login the
FlutterFlow app looks for users/{uid}, doesn't find it, and creates a second,
bare doc - so every console-created user ended up duplicated, and devices
assigned in the console pointed at the doc the app never reads.

Now the profile is written to users/{uid} with created_time set, and the email
is lowercased to match what Firebase Auth stores. If the Firestore write
fails, the just-created Auth account is deleted so no orphan is left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 15:31:34 +03:00
co-authored by Claude Opus 5.5
parent 3f3d2cc63c
commit c1df3b5aa3
2 changed files with 25 additions and 8 deletions
+22 -7
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from google.cloud.firestore_v1 import DocumentReference, ArrayUnion, ArrayRemove
@@ -116,30 +116,45 @@ def get_user(user_doc_id: str) -> UserInDB:
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."""
immediately) plus the matching Firestore profile document.
The profile doc ID MUST be the Firebase Auth uid. The FlutterFlow app looks
up `users/{uid}` on login and creates a bare doc there if it's missing, and
MQTT auth (mqtt/app_users.py) + device `user_list` paths assume the same.
"""
if not data.password or len(data.password) < 6:
raise ValidationError("Password must be at least 6 characters.")
if not data.email:
# Firebase Auth lowercases emails; store the same form so the profile
# matches what the app sees for the signed-in user.
email = (data.email or "").strip().lower()
if not email:
raise ValidationError("Email is required.")
db = get_db()
doc_data = data.model_dump(exclude={"password"})
doc_data["email"] = email
doc_data["friendsList"] = []
doc_data["friendsInvited"] = []
doc_data["created_time"] = datetime.now(timezone.utc)
try:
firebase_user = firebase_auth.create_user(
email=data.email,
email=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.")
raise ValidationError(f"A user with email {email} already exists.")
doc_data["uid"] = firebase_user.uid
_, doc_ref = db.collection(COLLECTION).add(doc_data)
try:
db.collection(COLLECTION).document(firebase_user.uid).set(doc_data)
except Exception:
# Don't leave an Auth account behind with no profile doc.
firebase_auth.delete_user(firebase_user.uid)
raise
return UserInDB(id=doc_ref.id, **doc_data)
return UserInDB(id=firebase_user.uid, **_sanitize_dict(doc_data))
def update_user(user_doc_id: str, data: UserUpdate) -> UserInDB:
+3 -1
View File
@@ -64,7 +64,9 @@ flashed into NVS and the firmware uses it as its MQTT id. Legacy docs only have
- `PUT /api/devices/{id}` when the body contains `user_list`
(`devices.service.update_device`)
- Users are resolved by the **`uid` field** (a query), **never by doc ID**. Console-
created users get random doc IDs (`.add()`); FlutterFlow uses the uid as the doc ID.
created users used to get random doc IDs (`.add()`) — fixed 2026-09-30, the Console
now writes `users/{uid}` like FlutterFlow does — but older docs may still have a
random ID, so keep resolving by the `uid` field.
- A user is refused when `status == "blocked"`.
- Lookups are cached in-process for 60 s (`mqtt/app_users.py`). Assign/unassign,
update, block/unblock and delete call `invalidate(uid)`. If you add a new code path