from __future__ import annotations import json import secrets from datetime import UTC, datetime from uuid import uuid4 from app.core.crypto import generate_secret_token_urlsafe, hash_opaque_token from app.core.exceptions import DomainError from app.modules.sync import repository as repo from app.modules.sync.schemas import ( AckChangesRequest, AckChangesResponse, ChangeEventIn, ChangeEventOut, ConflictDetail, ConflictSummary, HeartbeatRequest, HeartbeatResponse, PairConfirmRequest, PairConfirmResponse, PairStartRequest, PairStartResponse, PullChangesRequest, PullChangesResponse, PushChangesRequest, PushChangesResponse, ResolveConflictRequest, SyncCapabilitiesResponse, ) from app.modules.sync.engine import SyncEngine from app.modules.users.models import User class SyncServiceError(DomainError): pass def get_capabilities() -> SyncCapabilitiesResponse: return SyncCapabilitiesResponse() def start_pairing(user: User, body: PairStartRequest) -> PairStartResponse: member = repo.get_member(user.id, body.enterprise_id) if not member or member.role != "admin": if not user.is_superuser: raise SyncServiceError("ENTERPRISE_ADMIN_ONLY") enterprise = repo.get_enterprise_by_id(body.enterprise_id) if not enterprise: raise SyncServiceError("ENTERPRISE_NOT_FOUND") code = f"{secrets.randbelow(900000) + 100000:06d}" session = repo.create_pairing_session(body.enterprise_id, code, hash_opaque_token(code)) return PairStartResponse(session_id=session.id, code=code, expires_at=session.expires_at) def confirm_pairing(body: PairConfirmRequest) -> PairConfirmResponse: code_hash = hash_opaque_token(body.code.strip()) session = repo.get_pairing_session_by_code_hash(code_hash) if not session: raise SyncServiceError("INVALID_PAIRING_CODE") if session.expires_at.replace(tzinfo=UTC) < datetime.now(UTC): raise SyncServiceError("PAIRING_CODE_EXPIRED") hub = repo.create_farm_hub( session.enterprise_id, body.hub_name or f"Hub {body.hub_site_id[:8]}", body.hub_site_id, body.hub_url, ) api_key = generate_secret_token_urlsafe(48) repo.create_hub_credential(hub.id, api_key) repo.confirm_pairing_session(session.id, hub.id) return PairConfirmResponse( farm_hub_id=hub.id, hub_site_id=hub.hub_site_id, api_key=api_key, enterprise_id=session.enterprise_id, ) def hub_heartbeat(hub, body: HeartbeatRequest) -> HeartbeatResponse: repo.update_hub_heartbeat(hub.farm_hub_id, body.wesp_version) return HeartbeatResponse(server_time=datetime.now(UTC)) def push_changes(hub, body: PushChangesRequest) -> PushChangesResponse: return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_push(body.events, origin="hub") def pull_changes(hub, body: PullChangesRequest) -> PullChangesResponse: return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_pull(hub.farm_hub_id, body.cursor, body.limit) def ack_changes(hub, body: AckChangesRequest) -> AckChangesResponse: return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_ack(hub.farm_hub_id, body) def list_conflicts_for_enterprise(enterprise_id: str) -> list[ConflictSummary]: rows = repo.list_conflicts(enterprise_id) return [ ConflictSummary( id=row.id, table_name=row.table_name, record_id=row.record_id, farm_hub_id=row.farm_hub_id, status=row.status, created_at=row.created_at, ) for row in rows ] def get_conflict_detail(conflict_id: str, enterprise_id: str) -> ConflictDetail: row = repo.get_conflict(conflict_id) if not row or row.enterprise_id != enterprise_id: raise SyncServiceError("CONFLICT_NOT_FOUND") return ConflictDetail( id=row.id, table_name=row.table_name, record_id=row.record_id, farm_hub_id=row.farm_hub_id, status=row.status, created_at=row.created_at, orchestrator_snapshot=json.loads(row.orchestrator_snapshot_json or "{}"), hub_snapshot=json.loads(row.hub_snapshot_json or "{}"), held_event_ids=json.loads(row.held_event_ids_json or "[]"), ) def resolve_conflict(conflict_id: str, enterprise_id: str, user_id: str, body: ResolveConflictRequest) -> None: SyncEngine(enterprise_id, "orchestrator").resolve_conflict(conflict_id, user_id, body.resolution) def get_sync_metrics(enterprise_id: str) -> dict: from sqlalchemy import func, select from app.core.database import session_scope from app.modules.sync.models import FarmHub, SyncConflict, SyncCursor, SyncEventLog, SyncOutbox with session_scope() as db: hubs = list(db.scalars(select(FarmHub).where(FarmHub.enterprise_id == enterprise_id))) outbox_pending = db.scalar( select(func.count()) .select_from(SyncOutbox) .where(SyncOutbox.enterprise_id == enterprise_id, SyncOutbox.status == "pending") ) conflicts_pending = db.scalar( select(func.count()) .select_from(SyncConflict) .where(SyncConflict.enterprise_id == enterprise_id, SyncConflict.status == "pending") ) max_seq = db.scalar( select(func.max(SyncEventLog.seq)).where(SyncEventLog.enterprise_id == enterprise_id) ) or 0 hub_metrics = [] for h in hubs: cursor = db.scalar( select(SyncCursor).where( SyncCursor.farm_hub_id == h.id, SyncCursor.direction == "inbound", ) ) lag = max(0, int(max_seq) - int(cursor.last_acked_seq if cursor else 0)) hub_metrics.append( { "farm_hub_id": h.id, "name": h.name, "hub_site_id": h.hub_site_id, "status": h.status, "last_seen": h.last_seen.isoformat() if h.last_seen else None, "sync_lag": lag, } ) return { "hubs": hub_metrics, "outbox_pending": int(outbox_pending or 0), "conflicts_pending": int(conflicts_pending or 0), "max_event_seq": int(max_seq), }