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.mdv 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¶
- 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.
- Custom fields immutable from sync —
phone,email,notessync neumaže ani nepřepíše. Edits v UI persistují napříč budoucími sync runs. - 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. - 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¶
- Push do
mainvapp-kontakty-apprepu → backend.yml redeploy - Hlas user (Michal / paincelebrator) — verifikujeme E2E:
- SQL backfill v
app_kontakty.clients(6 řádků očekáváme) - Vytvoř testovací firmu v platform admin UI → kontakt by se měl objevit do 5s
- Update adresy → kontakt se updatne
- Smaž kontakt v app-kontakty UI → další update event ho NEresurrektuje
- Update vendor app docs (
docs/user/funkce/kontakty.mdnebo 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.