Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
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),
}