Přeskočit obsah

Companies → app-kontakty sync

Status: active (2026-06-01) Závisí: docs/spec/ai-helper.md §7 (M2M auth), docs/spec/apps-gateway.md Implementace: core-api 030.x + app-kontakty 0.2.x

Cíl

Každá firma registrovaná na AVAX platformě (tabulka public.companies) se zrcadlí do app-kontakty jako kontakt typu klient nebo vendor. App-kontakty je single primary screen pro účetní/CRM workflow — uživatel nemá smysl manuálně přepisovat IČO, název, adresu při onboardingu nové firmy.

Vlastnictví dat

Pole Source of truth Editovatelné v app-kontakty
id (UUID) companies.id ne
ico platform ne (read-only mirror)
name platform ne (read-only mirror)
address platform ne (read-only mirror)
is_vendortype platform ne
is_activeis_deactivated platform ne
phone, email, notes, custom fields app-kontakty ano
is_user_deleted (tombstone) app-kontakty ano (UI smazání)

Core fields (platform-owned) přepisuje sync. Custom fields (app-owned) sync nesahá. Tombstone = user smazal v UI → sync ho víc neresurrektuje.

Type mapping

companies.is_vendor companies.is_active app-kontakty type
false true klient
true true vendor
* false flag is_deactivated=true (nemizí, jen označit)

Sync mechanizmus

Real-time: Redis pubsub

Core-api publishuje na kanál companies.changed:

{
  "action": "created" | "updated" | "deactivated" | "reactivated",
  "company": {
    "id": "uuid",
    "ico": "12345678",
    "name": "Acme Soft s.r.o.",
    "address": "Praha 1, ČR",
    "is_active": true,
    "is_vendor": false,
    "updated_at": "2026-06-01T10:23:45Z"
  }
}

Triggers (call site → action):

Call site action
services/auth.py:register_company() created
routers/org.py:update_company() updated
Future deactivate flow (admin) deactivated / reactivated

Publish je best-effort (try/except + warning log) — pubsub výpadek nesráží HTTP endpoint. Initial backfill + periodický reconcile (níže) drží konzistenci.

Initial backfill: M2M REST

App-kontakty kontejner při startupu (nebo když ztratí Redis spojení déle než 60s) zavolá:

GET /v1/companies?limit=500&cursor=<uuid>
Authorization: Bearer <M2M JWT s scope companies.read>

Response (cursor pagination po created_at, id):

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

App-kontakty pro každý item: UPSERT do app_kontakty.clients, kde: - WHERE id = company.id - IF row existuje + is_user_deleted=true → skip (tombstone) - ELSE → update core fields (ico, name, address, type), zachovat custom fields

M2M scope

Nový scope řetězec: companies.read

Convention shodná s existujícími AI helper scopes (ai-helper.chat, ai-helper.rag.query). Bez prefixu = platform-owned scope.

Vystavení scope app-kontakty M2M klientovi

App-kontakty má auto-issued M2M client (avax_m2m_5b7b4b55...) z create-app wizardu. Default scopes podle template = AI helper subset. Pro sync přidat companies.read do allowed_scopes:

UPDATE m2m_clients
SET allowed_scopes = array_append(allowed_scopes, 'companies.read')
WHERE client_id = 'avax_m2m_5b7b4b55d291e2f80d9e2fe055194143'
  AND NOT ('companies.read' = ANY(allowed_scopes));

Pro future apps: create-app wizard akceptuje --m2m-scopes flag nebo seed scope v template manifestu.

Endpoint auth

GET /v1/companies v routers/companies_sync.py:

def _require_m2m_scope(scope: str):
    async def dep(request: Request):
        auth = request.headers.get("authorization", "")
        if not auth.lower().startswith("bearer "):
            raise HTTPException(401, "Bearer token vyžadován")
        try:
            payload = decode_token(auth.split(" ", 1)[1])
        except Exception:
            raise HTTPException(401, "Neplatný/expirovaný token")
        if not payload.get("is_m2m"):
            raise HTTPException(403, "Endpoint vyžaduje M2M token")
        if scope not in (payload.get("scopes") or []):
            raise HTTPException(403, f"Vyžadován scope: {scope}")
        return payload
    return dep

App-kontakty datový model (návrh)

Vendor Claude implementuje per briefing. Klíčová tabulka:

CREATE TABLE app_kontakty.clients (
    -- Platform-mirrored core fields (read-only v UI)
    id              UUID PRIMARY KEY,           -- = companies.id
    ico             VARCHAR(8) NOT NULL,
    name            VARCHAR(255) NOT NULL,
    address         TEXT,
    type            VARCHAR(20) NOT NULL,       -- 'klient' | 'vendor'
    is_deactivated  BOOLEAN NOT NULL DEFAULT FALSE,
    platform_updated_at TIMESTAMPTZ NOT NULL,

    -- App-owned fields (editovatelné v UI)
    phone           VARCHAR(50),
    email           VARCHAR(255),
    notes           TEXT,

    -- Tombstone
    is_user_deleted BOOLEAN NOT NULL DEFAULT FALSE,
    user_deleted_at TIMESTAMPTZ,
    user_deleted_by UUID,                       -- = users.id (FK soft, cross-schema)

    -- Audit
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_clients_ico ON app_kontakty.clients(ico);
CREATE INDEX idx_clients_type ON app_kontakty.clients(type)
    WHERE is_user_deleted = FALSE;

Failure modes

Scénář Chování
Redis down při core-api publish warning log, sync zaostane → příští backfill ho dohoní
App-kontakty backend down event ztracen, backfill při restartu z M2M endpointu
Pubsub delivery, ale upsert padne log + Celery retry (vendor implementuje)
Platform smaže firmu (is_active=false) → app-kontakty má custom data row drží is_deactivated=true, custom fields zachovány
User smaže klienta → admin pak reactivate firmy tombstone držet (UI: "Tato firma byla smazána z kontaktů — chcete obnovit?")

Open questions

  1. Periodic reconcile — Celery task v app-kontakty každých 24h projet /v1/companies a opravit drift? Nebo věřit pubsub + backfill při startup? → Default: jen startup backfill. Periodic až po 1. incidentu.
  2. Bidirectional sync — app-kontakty by mohl pushnout custom field updates zpět do platformy? Out of scope pro v1.
  3. Delete platform side — pokud admin natvrdo smaže companies row (ne jen is_active=false), eventy nepublishujeme. Vendor row visí. Cleanup TODO.

Rollout

  1. Platform PR (tento spec + publish + endpoint + scope add SQL)
  2. Vendor Claude implementuje subscriber + tabulku + UI
  3. Manuální SQL: dopsat companies.read scope app-kontakty M2M clientovi
  4. App-kontakty restart → backfill 6 dnešních companies
  5. Smoke: vytvořit testovací firmu v platformě → ověřit objevení v app-kontakty do 5 sekund