from datetime import UTC, datetime from typing import Any from bson import ObjectId from motor.motor_asyncio import AsyncIOMotorDatabase from pymongo import ASCENDING from gapido_auth.domain.entities import RefreshSession, Role, User from gapido_auth.domain.ports import RefreshSessionRepository, UserRepository def _now() -> datetime: """Return timezone-aware UTC now for Mongo document timestamps.""" return datetime.now(UTC) def _user_from_doc(doc: dict[str, Any]) -> User: """Map a Mongo user document into the domain entity.""" return User( id=str(doc["_id"]), mobile=str(doc["mobile"]), role=Role(str(doc["role"])), is_active=bool(doc["is_active"]), created_at=doc["created_at"], updated_at=doc["updated_at"], ) def _session_from_doc(doc: dict[str, Any]) -> RefreshSession: """Map a Mongo refresh-session document into the domain entity.""" return RefreshSession( id=str(doc["_id"]), user_id=str(doc["user_id"]), token_hash=str(doc["token_hash"]), expires_at=doc["expires_at"], revoked_at=doc.get("revoked_at"), replaced_by_hash=doc.get("replaced_by_hash"), created_at=doc["created_at"], ) class MongoUserRepository(UserRepository): """MongoDB adapter for user documents and admin bootstrap.""" def __init__(self, db: AsyncIOMotorDatabase[Any]) -> None: """Bind the repository to the users collection.""" self._collection = db.users async def create_indexes(self) -> None: """Create indexes required for unique mobile lookup and role scans.""" await self._collection.create_index([("mobile", ASCENDING)], unique=True) await self._collection.create_index([("role", ASCENDING)]) async def get_by_id(self, user_id: str) -> User | None: """Return a user by Mongo ObjectId string, or None for invalid/missing ids.""" if not ObjectId.is_valid(user_id): return None doc = await self._collection.find_one({"_id": ObjectId(user_id)}) return _user_from_doc(doc) if doc else None async def get_by_mobile(self, mobile: str) -> User | None: """Return the user registered for a mobile number if one exists.""" doc = await self._collection.find_one({"mobile": mobile}) return _user_from_doc(doc) if doc else None async def get_or_create_user(self, mobile: str, role: Role = Role.USER) -> User: """Create a default active user for a verified mobile, or return the existing one.""" now = _now() await self._collection.update_one( {"mobile": mobile}, { "$setOnInsert": { "mobile": mobile, "role": role.value, "is_active": True, "created_at": now, }, "$set": {"updated_at": now}, }, upsert=True, ) user = await self.get_by_mobile(mobile) if user is None: raise RuntimeError("failed to create user") return user async def ensure_admin_user(self, mobile: str) -> User: """Idempotently seed or promote the configured admin mobile.""" now = _now() await self._collection.update_one( {"mobile": mobile}, { "$set": {"role": Role.ADMIN.value, "is_active": True, "updated_at": now}, "$setOnInsert": {"mobile": mobile, "created_at": now}, }, upsert=True, ) user = await self.get_by_mobile(mobile) if user is None: raise RuntimeError("failed to seed admin user") return user class MongoRefreshSessionRepository(RefreshSessionRepository): """MongoDB adapter for hashed refresh-token sessions.""" def __init__(self, db: AsyncIOMotorDatabase[Any]) -> None: """Bind the repository to the refresh_sessions collection.""" self._collection = db.refresh_sessions async def create_indexes(self) -> None: """Create indexes used for token lookup, user session scans, and revocation.""" await self._collection.create_index([("token_hash", ASCENDING)], unique=True) await self._collection.create_index([("user_id", ASCENDING), ("expires_at", ASCENDING)]) await self._collection.create_index([("revoked_at", ASCENDING)]) async def create(self, user_id: str, token_hash: str, expires_at: datetime) -> RefreshSession: """Persist a new active refresh session for a user.""" now = _now() result = await self._collection.insert_one( { "user_id": user_id, "token_hash": token_hash, "expires_at": expires_at, "revoked_at": None, "replaced_by_hash": None, "created_at": now, } ) doc = await self._collection.find_one({"_id": result.inserted_id}) if doc is None: raise RuntimeError("failed to create refresh session") return _session_from_doc(doc) async def get_active_by_hash(self, token_hash: str, now: datetime) -> RefreshSession | None: """Return a non-revoked, non-expired session by token hash.""" doc = await self._collection.find_one( {"token_hash": token_hash, "revoked_at": None, "expires_at": {"$gt": now}} ) return _session_from_doc(doc) if doc else None async def revoke( self, token_hash: str, now: datetime, replaced_by_hash: str | None = None ) -> None: """Mark a refresh session revoked, optionally linking its replacement hash.""" update: dict[str, Any] = {"revoked_at": now} if replaced_by_hash is not None: update["replaced_by_hash"] = replaced_by_hash await self._collection.update_one( {"token_hash": token_hash, "revoked_at": None}, {"$set": update}, )