from __future__ import annotations from datetime import UTC, datetime, timedelta from uuid import uuid4 from sqlalchemy import func, select from app.core.crypto import hash_opaque_token from app.core.database import session_scope from app.modules.sync.models import ( Enterprise, EnterpriseMember, FarmHub, HubCredential, HubPairingSession, SyncAppliedEvent, SyncConflict, SyncCursor, SyncEventLog, SyncOutbox, SyncRecordState, UserFarmAccess, ) def _detach(db, instance): db.refresh(instance) db.expunge(instance) return instance def get_enterprise_by_id(enterprise_id: str) -> Enterprise | None: with session_scope() as db: row = db.get(Enterprise, enterprise_id) return _detach(db, row) if row else None def get_enterprise_by_slug(slug: str) -> Enterprise | None: with session_scope() as db: row = db.scalar(select(Enterprise).where(Enterprise.slug == slug)) return _detach(db, row) if row else None def create_enterprise(name: str, slug: str) -> Enterprise: with session_scope() as db: row = Enterprise(name=name, slug=slug, status="active") db.add(row) db.flush() return _detach(db, row) def get_member(user_id: str, enterprise_id: str) -> EnterpriseMember | None: with session_scope() as db: row = db.scalar( select(EnterpriseMember).where( EnterpriseMember.user_id == user_id, EnterpriseMember.enterprise_id == enterprise_id, ) ) return _detach(db, row) if row else None def add_member(user_id: str, enterprise_id: str, role: str) -> EnterpriseMember: with session_scope() as db: row = EnterpriseMember(user_id=user_id, enterprise_id=enterprise_id, role=role) db.add(row) db.flush() return _detach(db, row) def list_members(enterprise_id: str) -> list[EnterpriseMember]: with session_scope() as db: rows = list( db.scalars( select(EnterpriseMember).where(EnterpriseMember.enterprise_id == enterprise_id) ) ) return [_detach(db, row) for row in rows] def set_member_role(user_id: str, enterprise_id: str, role: str) -> EnterpriseMember | None: with session_scope() as db: row = db.scalar( select(EnterpriseMember).where( EnterpriseMember.user_id == user_id, EnterpriseMember.enterprise_id == enterprise_id, ) ) if not row: return None row.role = role db.flush() return _detach(db, row) def list_farm_access(user_id: str, enterprise_id: str) -> list[str]: with session_scope() as db: rows = db.scalars( select(UserFarmAccess.farm_hub_id) .join(FarmHub, FarmHub.id == UserFarmAccess.farm_hub_id) .where(UserFarmAccess.user_id == user_id, FarmHub.enterprise_id == enterprise_id) ).all() return list(rows) def grant_farm_access(user_id: str, farm_hub_id: str) -> UserFarmAccess: with session_scope() as db: existing = db.scalar( select(UserFarmAccess).where( UserFarmAccess.user_id == user_id, UserFarmAccess.farm_hub_id == farm_hub_id, ) ) if existing: return _detach(db, existing) row = UserFarmAccess(user_id=user_id, farm_hub_id=farm_hub_id) db.add(row) db.flush() return _detach(db, row) def get_farm_hub_by_site_id(hub_site_id: str) -> FarmHub | None: with session_scope() as db: row = db.scalar(select(FarmHub).where(FarmHub.hub_site_id == hub_site_id)) return _detach(db, row) if row else None def get_farm_hub_by_id(farm_hub_id: str) -> FarmHub | None: with session_scope() as db: row = db.get(FarmHub, farm_hub_id) return _detach(db, row) if row else None def list_farm_hubs(enterprise_id: str) -> list[FarmHub]: with session_scope() as db: rows = list(db.scalars(select(FarmHub).where(FarmHub.enterprise_id == enterprise_id).order_by(FarmHub.name))) return [_detach(db, row) for row in rows] def create_pairing_session(enterprise_id: str, code: str, code_hash: str, ttl_minutes: int = 15) -> HubPairingSession: with session_scope() as db: row = HubPairingSession( enterprise_id=enterprise_id, code=code, code_hash=code_hash, expires_at=datetime.now(UTC) + timedelta(minutes=ttl_minutes), ) db.add(row) db.flush() return _detach(db, row) def get_pairing_session_by_code_hash(code_hash: str) -> HubPairingSession | None: with session_scope() as db: row = db.scalar( select(HubPairingSession).where( HubPairingSession.code_hash == code_hash, HubPairingSession.confirmed_at.is_(None), ) ) return _detach(db, row) if row else None def confirm_pairing_session(session_id: str, farm_hub_id: str) -> None: with session_scope() as db: row = db.get(HubPairingSession, session_id) if row: row.confirmed_at = datetime.now(UTC) row.farm_hub_id = farm_hub_id def create_farm_hub( enterprise_id: str, name: str, hub_site_id: str, url: str | None = None, ) -> FarmHub: with session_scope() as db: row = FarmHub( enterprise_id=enterprise_id, name=name, hub_site_id=hub_site_id, url=url, status="active", ) db.add(row) db.flush() return _detach(db, row) def create_hub_credential(farm_hub_id: str, api_key: str) -> HubCredential: from app.core.crypto import hash_opaque_token as _hash with session_scope() as db: row = HubCredential(farm_hub_id=farm_hub_id, secret_hash=_hash(api_key)) db.add(row) db.flush() return _detach(db, row) def verify_hub_credential(hub_site_id: str, api_key: str) -> FarmHub | None: from app.core.crypto import hash_opaque_token as _hash key_hash = _hash(api_key) with session_scope() as db: row = db.scalar( select(FarmHub) .join(HubCredential, HubCredential.farm_hub_id == FarmHub.id) .where( FarmHub.hub_site_id == hub_site_id, HubCredential.secret_hash == key_hash, HubCredential.revoked_at.is_(None), ) ) return _detach(db, row) if row else None def update_hub_heartbeat(farm_hub_id: str, wesp_version: str | None) -> None: with session_scope() as db: row = db.get(FarmHub, farm_hub_id) if row: row.last_seen = datetime.now(UTC) if wesp_version: row.wesp_version = wesp_version def next_seq(enterprise_id: str) -> int: with session_scope() as db: current = db.scalar( select(func.max(SyncEventLog.seq)).where(SyncEventLog.enterprise_id == enterprise_id) ) return int(current or 0) + 1 def event_exists(event_id: str) -> bool: with session_scope() as db: row = db.scalar(select(SyncEventLog.id).where(SyncEventLog.event_id == event_id)) return row is not None def append_event_log(**kwargs) -> SyncEventLog: with session_scope() as db: row = SyncEventLog(**kwargs) db.add(row) db.flush() return _detach(db, row) def applied_event_exists(site_id: str, event_id: str) -> bool: with session_scope() as db: row = db.scalar( select(SyncAppliedEvent.id).where( SyncAppliedEvent.site_id == site_id, SyncAppliedEvent.event_id == event_id, ) ) return row is not None def mark_applied(enterprise_id: str, site_id: str, event_id: str) -> None: with session_scope() as db: db.add( SyncAppliedEvent( enterprise_id=enterprise_id, site_id=site_id, event_id=event_id, ) ) def get_record_state(enterprise_id: str, table_name: str, record_id: str) -> SyncRecordState | None: with session_scope() as db: row = db.scalar( select(SyncRecordState).where( SyncRecordState.enterprise_id == enterprise_id, SyncRecordState.table_name == table_name, SyncRecordState.record_id == record_id, ) ) return _detach(db, row) if row else None def upsert_record_state( enterprise_id: str, table_name: str, record_id: str, agreed_version: int, agreed_hash: str, last_event_id: str, ) -> None: with session_scope() as db: row = db.scalar( select(SyncRecordState).where( SyncRecordState.enterprise_id == enterprise_id, SyncRecordState.table_name == table_name, SyncRecordState.record_id == record_id, ) ) if row: row.agreed_version = agreed_version row.agreed_hash = agreed_hash row.last_event_id = last_event_id row.updated_at = datetime.now(UTC) else: db.add( SyncRecordState( enterprise_id=enterprise_id, table_name=table_name, record_id=record_id, agreed_version=agreed_version, agreed_hash=agreed_hash, last_event_id=last_event_id, ) ) def list_conflicts(enterprise_id: str, status: str = "pending") -> list[SyncConflict]: with session_scope() as db: rows = list( db.scalars( select(SyncConflict) .where(SyncConflict.enterprise_id == enterprise_id, SyncConflict.status == status) .order_by(SyncConflict.created_at.desc()) ) ) return [_detach(db, row) for row in rows] def get_conflict(conflict_id: str) -> SyncConflict | None: with session_scope() as db: row = db.get(SyncConflict, conflict_id) return _detach(db, row) if row else None def get_or_create_cursor(enterprise_id: str, farm_hub_id: str, direction: str) -> SyncCursor: with session_scope() as db: row = db.scalar( select(SyncCursor).where( SyncCursor.farm_hub_id == farm_hub_id, SyncCursor.direction == direction, ) ) if row: return _detach(db, row) row = SyncCursor( enterprise_id=enterprise_id, farm_hub_id=farm_hub_id, direction=direction, last_acked_seq=0, last_pulled_seq=0, ) db.add(row) db.flush() return _detach(db, row) def update_cursor_ack(farm_hub_id: str, direction: str, last_acked_seq: int) -> None: with session_scope() as db: row = db.scalar( select(SyncCursor).where( SyncCursor.farm_hub_id == farm_hub_id, SyncCursor.direction == direction, ) ) if row: row.last_acked_seq = max(row.last_acked_seq, last_acked_seq) row.updated_at = datetime.now(UTC) def pull_events_since( enterprise_id: str, cursor: int, limit: int, *, exclude_origin_site_id: str | None = None, ) -> list[SyncEventLog]: with session_scope() as db: query = select(SyncEventLog).where( SyncEventLog.enterprise_id == enterprise_id, SyncEventLog.seq > cursor, ) if exclude_origin_site_id: query = query.where( (SyncEventLog.origin_site_id.is_(None)) | (SyncEventLog.origin_site_id != exclude_origin_site_id) ) rows = list(db.scalars(query.order_by(SyncEventLog.seq.asc()).limit(limit))) return [_detach(db, row) for row in rows] def get_farm_hub_site_id(farm_hub_id: str) -> str | None: with session_scope() as db: row = db.get(FarmHub, farm_hub_id) return row.hub_site_id if row else None