Companies → app-kontakty sync¶
Status: active (2026-06-01) Závisí:
docs/spec/ai-helper.md§7 (M2M auth),docs/spec/apps-gateway.mdImplementace: 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_vendor → type |
platform | ne |
is_active → is_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á:
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¶
- Periodic reconcile — Celery task v app-kontakty každých 24h projet
/v1/companiesa opravit drift? Nebo věřit pubsub + backfill při startup? → Default: jen startup backfill. Periodic až po 1. incidentu. - Bidirectional sync — app-kontakty by mohl pushnout custom field updates zpět do platformy? Out of scope pro v1.
- Delete platform side — pokud admin natvrdo smaže companies row (ne jen
is_active=false), eventy nepublishujeme. Vendor row visí. Cleanup TODO.
Rollout¶
- Platform PR (tento spec + publish + endpoint + scope add SQL)
- Vendor Claude implementuje subscriber + tabulku + UI
- Manuální SQL: dopsat
companies.readscope app-kontakty M2M clientovi - App-kontakty restart → backfill 6 dnešních companies
- Smoke: vytvořit testovací firmu v platformě → ověřit objevení v app-kontakty do 5 sekund