Přeskočit obsah

Briefing pro druhého Claude (app-kontakty): companies sync

Komu: Claude session pracující v repu avax-apps/app-kontakty-app. Od koho: Platform Claude (avax-platform), 2026-06-01. Spec: docs/spec/companies-contacts-sync.md v avax-platform repu.

TL;DR

Firmy registrované na AVAX platformě se mají automaticky objevit v app-kontakty jako kontakty. Platform strana je hotová: Redis pubsub companies.changed + M2M endpoint GET /v1/companies (scope companies.read).

Tvoje práce: subscriber + tabulka v app_kontakty schema + UI vrstva + backfill on startup.

Co je hotové na platformě (smoke-tested 2026-06-01)

Komponenta Lokace Stav
Spec docs/spec/companies-contacts-sync.md
Publish service backend/app/services/companies_events.py
Hook v register_company backend/app/services/auth.py
Hook v update_company (PUT /companies/{id}) backend/app/routers/org.py
Backfill endpoint GET /v1/companies backend/app/routers/companies_sync.py
Scope companies.read přidaný k app-kontakty M2M klientu DB
Deploy na avaxdev http://127.0.0.1:8000/v1/companies ✅ healthy

Verifikace endpointu (z avaxdev)

# Issue M2M token
RESP=$(curl -s -X POST http://127.0.0.1:8000/auth/token \
  -H 'Content-Type: application/json' \
  -d '{"grant_type":"client_credentials","client_id":"'"$AVAX_M2M_CLIENT_ID"'",
       "client_secret":"'"$AVAX_M2M_CLIENT_SECRET"'","scope":"companies.read"}')
TOKEN=$(echo "$RESP" | python -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')

# Backfill page
curl -H "Authorization: Bearer $TOKEN" \
  'http://127.0.0.1:8000/v1/companies?limit=200'

Response shape:

{
  "items": [{"id":"<uuid>","ico":"...","name":"...","address":"...",
             "is_active":true,"is_vendor":false,"updated_at":"..."}],
  "next_cursor": "<uuid_or_null>"
}

Pubsub kanál companies.changed ověřen (3 subscribers OK):

{"action":"created|updated|deactivated|reactivated",
 "company":{"id":"...","ico":"...","name":"...","address":"...",
            "is_active":bool,"is_vendor":bool,"updated_at":"..."}}

Tvoje práce (app-kontakty backend)

1. Tabulka app_kontakty.clients

Migrace 0001_initial.py (nebo další pokud už máš):

def upgrade():
    op.execute("CREATE SCHEMA IF NOT EXISTS app_kontakty")
    op.create_table(
        "clients",
        # Platform-mirrored (read-only v UI)
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
        sa.Column("ico", sa.String(8), nullable=False),
        sa.Column("name", sa.String(255), nullable=False),
        sa.Column("address", sa.Text(), nullable=True),
        sa.Column("type", sa.String(20), nullable=False),     # 'klient' | 'vendor'
        sa.Column("is_deactivated", sa.Boolean(), nullable=False, server_default="false"),
        sa.Column("platform_updated_at", sa.DateTime(timezone=True), nullable=False),
        # App-owned (UI editable)
        sa.Column("phone", sa.String(50), nullable=True),
        sa.Column("email", sa.String(255), nullable=True),
        sa.Column("notes", sa.Text(), nullable=True),
        # Tombstone
        sa.Column("is_user_deleted", sa.Boolean(), nullable=False, server_default="false"),
        sa.Column("user_deleted_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("user_deleted_by", postgresql.UUID(as_uuid=True), nullable=True),
        # Audit
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
                  server_default=sa.text("now()")),
        sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
                  server_default=sa.text("now()")),
        schema="app_kontakty",
    )
    op.create_index("idx_clients_ico", "clients", ["ico"], schema="app_kontakty")
    op.create_index("idx_clients_type_active", "clients", ["type"],
                    schema="app_kontakty",
                    postgresql_where=sa.text("is_user_deleted = false"))

2. UPSERT logika (sync handler)

async def upsert_company_as_client(db, payload: dict) -> None:
    """Sjednocený upsert volaný z pubsub subscriber + backfill loop."""
    c = payload["company"]
    company_id = uuid.UUID(c["id"])

    existing = (await db.execute(
        select(Client).where(Client.id == company_id)
    )).scalar_one_or_none()

    if existing and existing.is_user_deleted:
        # Tombstone — nepropíšeme zpět
        return

    client_type = "vendor" if c["is_vendor"] else "klient"
    is_deact = not c["is_active"]

    if existing:
        # Update core fields jen, custom fields zachovat
        existing.ico = c["ico"]
        existing.name = c["name"]
        existing.address = c.get("address")
        existing.type = client_type
        existing.is_deactivated = is_deact
        existing.platform_updated_at = parse_iso(c["updated_at"])
        existing.updated_at = func.now()
    else:
        db.add(Client(
            id=company_id,
            ico=c["ico"],
            name=c["name"],
            address=c.get("address"),
            type=client_type,
            is_deactivated=is_deact,
            platform_updated_at=parse_iso(c["updated_at"]),
        ))
    await db.commit()

3. Redis subscriber (FastAPI lifespan)

# backend/app/services/sync_subscriber.py
import asyncio, json, logging
from redis.asyncio import from_url

log = logging.getLogger(__name__)

async def subscribe_companies_changed(redis_url: str, db_factory):
    redis = from_url(redis_url, decode_responses=True)
    pubsub = redis.pubsub()
    await pubsub.subscribe("companies.changed")
    log.info("Subscribed to companies.changed")
    async for msg in pubsub.listen():
        if msg["type"] != "message":
            continue
        try:
            payload = json.loads(msg["data"])
            async with db_factory() as db:
                await upsert_company_as_client(db, payload)
        except Exception:
            log.exception("Failed processing companies.changed message")

# backend/app/main.py lifespan
@asynccontextmanager
async def lifespan(app: FastAPI):
    # Backfill on startup
    await backfill_companies()
    # Subscribe to live updates
    task = asyncio.create_task(
        subscribe_companies_changed(settings.REDIS_URL, get_db)
    )
    yield
    task.cancel()

4. Initial backfill

async def backfill_companies():
    """Při startupu nebo po reconnectu projet GET /v1/companies cursor pagination."""
    async with httpx.AsyncClient(base_url=settings.AVAX_API_URL) as http:
        token = await issue_m2m_token(scope="companies.read")
        cursor = None
        while True:
            params = {"limit": 200}
            if cursor:
                params["cursor"] = cursor
            r = await http.get("/v1/companies", params=params,
                               headers={"Authorization": f"Bearer {token}"})
            r.raise_for_status()
            data = r.json()
            async with get_db() as db:
                for item in data["items"]:
                    await upsert_company_as_client(db, {"action": "created", "company": item})
            cursor = data.get("next_cursor")
            if not cursor:
                break

5. UI pravidla (vendor)

Pole UI chování
ico, name, address, type read-only (disabled input s tooltip "Spravováno na AVAX platformě")
is_deactivated=true Badge "Deaktivováno" + zařadit do filtru "Archív"
phone, email, notes editovatelné
Smazat tlačítko nastaví is_user_deleted=true + user_deleted_at + user_deleted_by (NE hard delete)
Obnovit smazaného (admin only) hard reset tombstone — is_user_deleted=false

Klíčové kontrakty

  1. Tombstone semantics — user-deleted klient se NEPROPISUJE zpátky ani při budoucím update eventu. Sync ho přeskočí. Reactivate v UI je manual akce.
  2. Custom fields immutable from syncphone, email, notes sync neumaže ani nepřepíše. Edits v UI persistují napříč budoucími sync runs.
  3. All companies in scope — sync zrcadlí všech 6 dnešních firem (incl. AVAXIS a Kovomalt jako vendor). Filter v UI je client-side.
  4. Best-effort pubsub — Redis výpadek = events ztracené. Backfill při startupu / reconnectu drží konzistenci. Žádný retry mechanismus na platformě.

Local test bez živé platformy

# Lokální test bez celého platform stacku:
test_payload = {
    "action": "created",
    "company": {
        "id": "11111111-1111-1111-1111-111111111111",
        "ico": "12345678",
        "name": "Test s.r.o.",
        "address": "Test 1",
        "is_active": True,
        "is_vendor": False,
        "updated_at": "2026-06-01T10:00:00Z",
    },
}
await upsert_company_as_client(db, test_payload)
assert (await db.get(Client, "11111111...")).type == "klient"

Až budeš hotov

  1. Push do main v app-kontakty-app repu → backend.yml redeploy
  2. Hlas user (Michal / paincelebrator) — verifikujeme E2E:
  3. SQL backfill v app_kontakty.clients (6 řádků očekáváme)
  4. Vytvoř testovací firmu v platform admin UI → kontakt by se měl objevit do 5s
  5. Update adresy → kontakt se updatne
  6. Smaž kontakt v app-kontakty UI → další update event ho NEresurrektuje
  7. Update vendor app docs (docs/user/funkce/kontakty.md nebo equivalent) s "klienti jsou auto-importovaní z platformy" notice.

Pokud něco nesedí

Hlas platform Claude (avax-platform repo) — nejspíš mezera ve spec doc nebo publish event payload. Spec je single source of truth, NE tento briefing.