"""Sync service for VoIdea. Handles pull/push sync of user data across devices. Change tracking via updated_at timestamps. """ from datetime import datetime, timezone from typing import Any from uuid import UUID, uuid4 from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.idea import Idea from app.models.user import User class SyncService: def __init__(self, db: AsyncSession): self.db = db async def pull(self, user: User, last_sync: datetime | None = None) -> dict: """Pull changes since last_sync. Args: user: Authenticated user last_sync: Last sync timestamp (None = full sync) Returns: Dict with changes list and sync_token """ query = select(Idea).where(Idea.user_id == user.id) if last_sync: query = query.where(Idea.updated_at > last_sync) result = await self.db.execute(query) ideas = result.scalars().all() changes = [ { "entity": "idea", "action": "update", "id": str(idea.id), "data": { "title": idea.title, "content": idea.content, "status": idea.status, "updated_at": idea.updated_at.isoformat() if idea.updated_at else None, }, } for idea in ideas ] return { "status": "ok", "changes": changes, "sync_token": datetime.now(timezone.utc).isoformat(), } async def push(self, user: User, device_id: str, changes: list[dict[str, Any]]) -> dict: """Push changes from device. Args: user: Authenticated user device_id: Source device identifier changes: List of change dicts Returns: Dict with applied changes and conflicts """ applied = [] conflicts = [] for change in changes: entity = change.get("entity", "idea") action = change.get("action", "update") change_id = change.get("id") if entity != "idea": applied.append({"id": change_id, "status": "skipped", "reason": "unsupported_entity"}) continue data = change.get("data", {}) if action == "delete" and change_id: query = select(Idea).where(Idea.id == UUID(change_id), Idea.user_id == user.id) result = await self.db.execute(query) idea = result.scalar_one_or_none() if idea: await self.db.delete(idea) applied.append({"id": change_id, "status": "deleted"}) else: applied.append({"id": change_id, "status": "not_found"}) continue if change_id: query = select(Idea).where(Idea.id == UUID(change_id), Idea.user_id == user.id) result = await self.db.execute(query) existing = result.scalar_one_or_none() if existing: incoming_ts = data.get("updated_at") if incoming_ts and existing.updated_at: incoming_dt = datetime.fromisoformat(incoming_ts) if existing.updated_at.replace(tzinfo=timezone.utc) > incoming_dt.replace(tzinfo=timezone.utc): conflicts.append({ "id": change_id, "server_version": existing.updated_at.isoformat(), "client_version": incoming_ts, "message": "Server has newer version", }) continue for key, value in data.items(): if key != "updated_at" and hasattr(existing, key): setattr(existing, key, value) applied.append({"id": change_id, "status": "updated"}) else: new_idea = Idea( id=UUID(change_id) if change_id else uuid4(), user_id=user.id, title=data.get("title", ""), content=data.get("content", ""), status=data.get("status", "draft"), ) self.db.add(new_idea) applied.append({"id": change_id or str(new_idea.id), "status": "created"}) else: new_idea = Idea( user_id=user.id, title=data.get("title", ""), content=data.get("content", ""), status=data.get("status", "draft"), ) self.db.add(new_idea) applied.append({"id": str(new_idea.id), "status": "created"}) await self.db.commit() return { "status": "ok", "changes": applied, "conflicts": conflicts, "sync_token": datetime.now(timezone.utc).isoformat(), }