import re import uuid from datetime import UTC, datetime from typing import Any, Literal from motor.motor_asyncio import AsyncIOMotorDatabase from pymongo import ReturnDocument from app.schemas.document import CreateType, DocumentCreate, DocumentUpdate, MemberRole COLLECTION = "documents" SortField = Literal["updated_at", "last_visited_at"] async def ensure_indexes(db: AsyncIOMotorDatabase) -> None: col = db[COLLECTION] await col.create_index([("owner_id", 1), ("updated_at", -1)]) await col.create_index([("owner_id", 1), ("last_visited_at", -1)]) await col.create_index([("owner_id", 1), ("stead_id", 1)]) await col.create_index([("owner_id", 1), ("type", 1)]) await col.create_index([("owner_id", 1), ("starred", 1)]) await col.create_index([("members.user_id", 1)]) await col.create_index([("title", "text"), ("created_by", "text")]) def _serialize(doc: dict[str, Any]) -> dict[str, Any]: return { "id": doc["_id"], "type": doc["type"], "title": doc["title"], "template_id": doc.get("template_id"), "stead_id": doc.get("stead_id"), "parent_page_id": doc.get("parent_page_id"), "content": doc.get("content", {}), "created_by": doc["created_by"], "created_at": doc["created_at"], "updated_at": doc["updated_at"], "starred": doc.get("starred", False), "last_visited_at": doc.get("last_visited_at"), "owner_id": doc.get("owner_id"), "visibility": doc.get("visibility", "private"), "members": doc.get("members", []), "workspace_id": doc.get("workspace_id"), } def access_query(user_id: int) -> dict[str, Any]: return {"$or": [{"owner_id": user_id}, {"members.user_id": user_id}]} def can_edit(doc: dict[str, Any], user_id: int) -> bool: if doc.get("owner_id") == user_id: return True for member in doc.get("members", []): if member.get("user_id") == user_id and member.get("role") == "editor": return True return False async def create_document( db: AsyncIOMotorDatabase, owner_id: int, created_by: str, data: DocumentCreate, ) -> dict[str, Any]: if data.parent_page_id: parent = await db[COLLECTION].find_one({"_id": data.parent_page_id, **access_query(owner_id)}) if parent is None or parent.get("type") == "stead": raise ValueError("Parent page is not accessible or is not a page") now = datetime.now(UTC) doc_id = str(uuid.uuid4()) doc = { "_id": doc_id, "owner_id": owner_id, "type": data.type, "title": data.title, "template_id": data.template_id, "stead_id": data.stead_id, "parent_page_id": data.parent_page_id, "content": data.content, "created_by": created_by, "created_at": now, "updated_at": now, "starred": False, "last_visited_at": now, "visibility": "private", "members": [], "workspace_id": data.workspace_id, } await db[COLLECTION].insert_one(doc) return _serialize(doc) async def get_document( db: AsyncIOMotorDatabase, doc_id: str, user_id: int, ) -> dict[str, Any] | None: doc = await db[COLLECTION].find_one({"_id": doc_id, **access_query(user_id)}) return _serialize(doc) if doc else None async def list_documents( db: AsyncIOMotorDatabase, user_id: int, doc_type: CreateType | None = None, stead_id: str | None = None, parent_page_id: str | None = None, workspace_id: int | None = None, starred: bool | None = None, sort: SortField = "updated_at", ) -> list[dict[str, Any]]: query: dict[str, Any] = access_query(user_id) extras: dict[str, Any] = {} if doc_type: extras["type"] = doc_type if stead_id: extras["stead_id"] = stead_id if parent_page_id: extras["parent_page_id"] = parent_page_id if starred is not None: extras["starred"] = starred if workspace_id is not None: extras["workspace_id"] = workspace_id if extras: query = {"$and": [query, extras]} cursor = db[COLLECTION].find(query).sort(sort, -1) return [_serialize(doc) async for doc in cursor] async def search_documents( db: AsyncIOMotorDatabase, user_id: int, q: str, limit: int = 20, ) -> list[dict[str, Any]]: term = q.strip() if not term: return [] pattern = re.escape(term) regex = {"$regex": pattern, "$options": "i"} query = { "$and": [ access_query(user_id), { "$or": [ {"title": regex}, {"type": regex}, {"created_by": regex}, {"content.body": regex}, {"content.bodyHtml": regex}, ] }, ] } cursor = db[COLLECTION].find(query).sort([("last_visited_at", -1), ("updated_at", -1)]).limit(limit) return [_serialize(doc) async for doc in cursor] async def update_document( db: AsyncIOMotorDatabase, doc_id: str, user_id: int, data: DocumentUpdate, ) -> dict[str, Any] | None: existing = await db[COLLECTION].find_one({"_id": doc_id, **access_query(user_id)}) if existing is None: return None if not can_edit(existing, user_id): return None if data.content is not None or data.title is not None: from app.services.history_service import create_version await create_version( db, existing, user_id, existing.get("created_by", "Collaborator"), "Before update", ) if "parent_page_id" in data.model_fields_set: if data.parent_page_id == doc_id: return None if data.parent_page_id is not None: parent = await db[COLLECTION].find_one({"_id": data.parent_page_id, **access_query(user_id)}) if parent is None or parent.get("type") == "stead": return None updates: dict[str, Any] = {"updated_at": datetime.now(UTC)} if data.title is not None: updates["title"] = data.title if data.content is not None: updates["content"] = data.content if data.starred is not None: updates["starred"] = data.starred if data.stead_id is not None: updates["stead_id"] = data.stead_id if data.workspace_id is not None: updates["workspace_id"] = data.workspace_id if "parent_page_id" in data.model_fields_set: updates["parent_page_id"] = data.parent_page_id result = await db[COLLECTION].find_one_and_update( {"_id": doc_id}, {"$set": updates}, return_document=ReturnDocument.AFTER, ) return _serialize(result) if result else None async def record_visit( db: AsyncIOMotorDatabase, doc_id: str, user_id: int, ) -> dict[str, Any] | None: now = datetime.now(UTC) result = await db[COLLECTION].find_one_and_update( {"_id": doc_id, **access_query(user_id)}, {"$set": {"last_visited_at": now}}, return_document=ReturnDocument.AFTER, ) return _serialize(result) if result else None async def add_member( db: AsyncIOMotorDatabase, doc_id: str, owner_id: int, member: dict[str, Any], role: MemberRole, ) -> dict[str, Any] | None: existing = await db[COLLECTION].find_one({"_id": doc_id, "owner_id": owner_id}) if existing is None: return None members = list(existing.get("members", [])) members = [m for m in members if m.get("user_id") != member["user_id"]] members.append( { "user_id": member["user_id"], "role": role, "name": member.get("name", ""), "email": member.get("email", ""), } ) result = await db[COLLECTION].find_one_and_update( {"_id": doc_id, "owner_id": owner_id}, { "$set": { "members": members, "visibility": "members", "updated_at": datetime.now(UTC), } }, return_document=ReturnDocument.AFTER, ) return _serialize(result) if result else None async def remove_member( db: AsyncIOMotorDatabase, doc_id: str, owner_id: int, member_user_id: int, ) -> dict[str, Any] | None: existing = await db[COLLECTION].find_one({"_id": doc_id, "owner_id": owner_id}) if existing is None: return None members = [m for m in existing.get("members", []) if m.get("user_id") != member_user_id] visibility = "members" if members else "private" result = await db[COLLECTION].find_one_and_update( {"_id": doc_id, "owner_id": owner_id}, { "$set": { "members": members, "visibility": visibility, "updated_at": datetime.now(UTC), } }, return_document=ReturnDocument.AFTER, ) return _serialize(result) if result else None async def delete_document( db: AsyncIOMotorDatabase, doc_id: str, owner_id: int, ) -> bool: result = await db[COLLECTION].delete_one({"_id": doc_id, "owner_id": owner_id}) return result.deleted_count > 0