Roger, **não é o ASM de novo** nem problema de token Cloudflare. É outro erro — e a mensagem sobre o token **engana**.

## Diagnóstico

Na tela aparece **DOMÍNIO betinplace** (sem `.com`). A Cloudflare recusa isso:

| Domínio testado | Resultado API |
|-----------------|---------------|
| `betinplace` | **1002 — Invalid domain** |
| `betinplace.com` | **OK** — zona já existe na conta Ibytera |

Reproduzi agora na VM112:

```json
POST /api/onboarding/dns/cloudflare/provision-zone
{ "domain": "betinplace" }
→ code 1002 Invalid domain

{ "domain": "betinplace.com" }
→ ready: true, zona já existia
```

O token **funciona** (`cfut_…`, 53 chars). A zona `betinplace.com` está na conta — nameservers `norah.ns.cloudflare.com` / `rohin.ns.cloudflare.com`.

---

## Por que a mensagem do token aparece?

O backend trata **qualquer** erro Cloudflare com a mesma dica genérica:

> *Token precisa «Account - Zone - Create» e «Zone - Edit»*

Mesmo quando o erro real é **domínio inválido** (1002). Isso é bug de UX no wizard, não falta de permissão no token.

---

## O que fazer agora

1. **Voltar** ao passo **Domínio** (seta « voltar » no wizard)
2. Corrigir para **`betinplace.com`** (com TLD completo)
3. Avançar de novo para DNS → «Trazer DNS para o portal»

Ou abrir sessão limpa:

`https://onboard.ligbox.com.br/onboard?domain=betinplace.com`

(Ctrl+Shift+R se a sessão antiga estiver em cache no `sessionStorage`)

---

## Resumo

| Pergunta | Resposta |
|----------|----------|
| ASM / handoff de novo? | **Não** — erro real de DNS Cloudflare |
| Token sem permissão? | **Não** — `betinplace.com` responde OK |
| Causa | Sessão com domínio **`betinplace`** sem `.com` |
| Solução | Usar **`betinplace.com`** no passo Domínio |

Quer que eu corrija o wizard para:
- **rejeitar** domínio sem TLD no passo 1, e
- mostrar mensagem clara (*«use formato empresa.com»*) em vez da dica genérica do token?

[REDACTED]


<user_query>
coloque esta informacao na base de conhecimento ,por favor
</user_query>


Procurando onde fica a base de conhecimento no projeto para registrar esse diagnóstico.

[REDACTED]


[REDACTED]


<user_query>
e sim motre a mensdagde erro claro  use formato seudominio.com
</user_query>


Implementando mensagem de erro clara para domínio inválido e validação no passo 1 do wizard.

[REDACTED]

Shell

sshpass -p '@betinplace' ssh root@10.10.10.112 "sed -n '360,450p' /opt/ligbox-wizard/backend/app/routers/onboarding.py; grep -n 'submitDomain\|validate-domain\|setDomain\|domain.*input' /opt/ligbox-wizard/frontend/src/App.jsx | head -25"

Read backend error handling and frontend domain submit


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "sed -n '130,180p;660,720p;1370,1430p' /opt/ligbox-wizard/frontend/src/App.jsx"

Read errorMessage and domain step UI

sshpass -p '@betinplace' ssh root@10.10.10.112 "head -30 /opt/ligbox-wizard/backend/app/routers/onboarding.py; grep -n 'DomainRequest\|ValidateDomain' /opt/ligbox-wizard/backend/app/routers/onboarding.py | head -10"

Find request models in onboarding router


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "sed -n '718,760p;575,615p' /opt/ligbox-wizard/frontend/src/App.jsx"

Read choosePortalDns and api error handling


Implementando validação e mensagens claras no backend e no frontend do wizard.

[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 'cat > /opt/ligbox-wizard/backend/app/services/domain_format.py << '"'"'PY'"'"'
"""Validação de domínio FQDN para onboarding (passo Domínio / DNS Cloudflare)."""

from __future__ import annotations

import re

FQDN_RE = re.compile(
    r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
)

INVALID_DOMAIN_MESSAGE = (
    "Domínio inválido. Use o formato completo, por exemplo seudominio.com ou empresa.com.br."
)
INVALID_DOMAIN_HINT = (
    "Indique o domínio com extensão (.com, .com.br, etc.) — "
    "não use só o nome (ex.: betinplace → betinplace.com)."
)

def normalize_domain(raw: str) -> str:
    return (raw or "").lower().strip().rstrip(".")

def is_valid_primary_domain(domain: str) -> bool:
    d = normalize_domain(domain)
    if not d or len(d) > 253 or "." not in d:
        return False
    return bool(FQDN_RE.match(d))

def validate_primary_domain(domain: str) -> None:
    if not is_valid_primary_domain(domain):
        raise ValueError(INVALID_DOMAIN_MESSAGE)

def invalid_domain_http_detail() -> dict:
    return {
        "code": "invalid_domain_format",
        "message": INVALID_DOMAIN_MESSAGE,
        "hint": INVALID_DOMAIN_HINT,
    }

def cloudflare_error_http_detail(exc: Exception, domain: str) -> dict:
    raw = str(exc)
    lower = raw.lower()
    if "1002" in raw and "invalid domain" in lower:
        return invalid_domain_http_detail()
    if not is_valid_primary_domain(domain):
        return invalid_domain_http_detail()
    if "zone.create" in lower or "permission" in lower or "zone - create" in lower:
        return {
            "code": "cloudflare_provision_failed",
            "message": raw,
            "hint": "Token precisa «Account - Zone - Create» e «Zone - Edit».",
        }
    return {
        "code": "cloudflare_provision_failed",
        "message": raw,
        "hint": "Verifique o domínio e o token Cloudflare Ibytera.",
    }
PY'

Create domain_format.py validation module on VM112

sshpass -p '@betinplace' ssh root@10.10.10.112 "python3 << 'PY'
from pathlib import Path
p = Path('/opt/ligbox-wizard/backend/app/routers/onboarding.py')
text = p.read_text()
if 'domain_format' not in text:
    text = text.replace(
        'from app.services.cloudflare import CloudflareDNS, CloudflareError, is_cloudflare_sandbox, mail_dns_records, wizard_nameservers',
        'from app.services.cloudflare import CloudflareDNS, CloudflareError, is_cloudflare_sandbox, mail_dns_records, wizard_nameservers\n'
        'from app.services.domain_format import cloudflare_error_http_detail, invalid_domain_http_detail, normalize_domain, validate_primary_domain',
    )
old_validate = '''def validate_domain(body: ValidateDomainRequest, request: Request):
    get_session_from_request(request)
    domain = body.domain.lower().strip()'''
new_validate = '''def validate_domain(body: ValidateDomainRequest, request: Request):
    get_session_from_request(request)
    domain = normalize_domain(body.domain)
    try:
        validate_primary_domain(domain)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e'''
text = text.replace(old_validate, new_validate)
old_prov = '''def provision_cloudflare_zone(body: DomainRequest, request: Request):
    get_session_from_request(request)
    domain = body.domain.lower().strip()
    if not settings.cloudflare_api_token:'''
new_prov = '''def provision_cloudflare_zone(body: DomainRequest, request: Request):
    get_session_from_request(request)
    domain = normalize_domain(body.domain)
    try:
        validate_primary_domain(domain)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e
    if not settings.cloudflare_api_token:'''
text = text.replace(old_prov, new_prov)
old_except = '''    except CloudflareError as e:
        activity_log.error(f\"Provision zona falhou: {e}\", source=\"cloudflare\")
        raise HTTPException(
            status_code=502,
            detail={
                \"code\": \"cloudflare_provision_failed\",
                \"message\": str(e),
                \"hint\": \"Token precisa «Account - Zone - Create» e «Zone - Edit».\",
            },
        ) from e'''
new_except = '''    except CloudflareError as e:
        activity_log.error(f\"Provision zona falhou: {e}\", source=\"cloudflare\")
        raise HTTPException(
            status_code=502,
            detail=cloudflare_error_http_detail(e, domain),
        ) from e'''
text = text.replace(old_except, new_except)
p.write_text(text)
print('onboarding.py patched')
PY"

Patch onboarding.py domain validation and error hints

sshpass -p '@betinplace' ssh root@10.10.10.112 "python3 << 'PY'
from pathlib import Path
p = Path('/opt/ligbox-wizard/frontend/src/domainUtils.js')
text = p.read_text()
if 'validatePrimaryDomain' not in text:
    insert = '''
export function validatePrimaryDomain(raw) {
  const host = (raw || '').trim().toLowerCase().replace(/\\.+$/, '')
  if (!host) {
    return { ok: false, error: 'Indique o domínio da empresa.', host: '' }
  }
  if (!host.includes('.')) {
    return {
      ok: false,
      error: 'Domínio inválido. Use o formato completo, por exemplo seudominio.com ou empresa.com.br.',
      host,
    }
  }
  if (!HOST_RE.test(host)) {
    return {
      ok: false,
      error: 'Formato inválido. Use o formato seudominio.com ou empresa.com.br.',
      host,
    }
  }
  return { ok: true, error: '', host }
}
'''
    text = text.rstrip() + insert + '\n'
    p.write_text(text)
    print('domainUtils.js patched')
else:
    print('domainUtils.js already has validatePrimaryDomain')
PY"

Add validatePrimaryDomain to domainUtils.js


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "tail -35 /opt/ligbox-wizard/frontend/src/domainUtils.js"

Verify domainUtils.js validatePrimaryDomain

sshpass -p '@betinplace' ssh root@10.10.10.112 "python3 << 'PY'
from pathlib import Path
p = Path('/opt/ligbox-wizard/frontend/src/App.jsx')
text = p.read_text()
# errorMessage
old_em = '''function errorMessage(data, fallback) {
  const d = data?.detail
  if (typeof d === 'object' && d?.message) return d.message
  if (typeof d === 'string') return d
  return data?.message || fallback
}'''
new_em = '''function errorMessage(data, fallback) {
  const d = data?.detail
  if (typeof d === 'object' && d?.message) {
    const parts = [d.message]
    if (d.hint && !String(d.message).includes(d.hint)) parts.push(d.hint)
    return parts.join('\\n\\n')
  }
  if (typeof d === 'string') return d
  return data?.message || fallback
}'''
text = text.replace(old_em, new_em)
# import validatePrimaryDomain
if 'validatePrimaryDomain' not in text:
    text = text.replace(
        '  validateMailAlias,',
        '  validateMailAlias,\n  validatePrimaryDomain,',
    )
# loadDnsStep validation
old_ld = '''    const dom = domain.trim().toLowerCase()
    if (!dom) {
      setError('Indique o domínio da empresa.')
      stopBusy()
      return
    }'''
new_ld = '''    const dom = domain.trim().toLowerCase().replace(/\\.+$/, '')
    if (!dom) {
      setError('Indique o domínio da empresa.')
      stopBusy()
      return
    }
    const domainCheck = validatePrimaryDomain(dom)
    if (!domainCheck.ok) {
      setError(domainCheck.error)
      stopBusy()
      return
    }'''
text = text.replace(old_ld, new_ld)
# choosePortalDns catch - simplify duplicate hint
old_cp = '''    } catch (e) {
      setError(e.message)
      if (e.detail?.hint) setError(`${e.message}\\n\\n${e.detail.hint}`)
      setDnsChoice(null)'''
new_cp = '''    } catch (e) {
      setError(e.message)
      setDnsChoice(null)'''
text = text.replace(old_cp, new_cp)
p.write_text(text)
print('App.jsx patched')
PY"

Patch App.jsx errorMessage and domain validation


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "grep -n 'errorMessage\|validatePrimaryDomain\|domainCheck\|e.detail?.hint' /opt/ligbox-wizard/frontend/src/App.jsx | head -20"

Verify App.jsx patches applied

sshpass -p '@betinplace' ssh root@10.10.10.112 "grep -n 'domain_format\|validate_primary_domain\|cloudflare_error_http' /opt/ligbox-wizard/backend/app/routers/onboarding.py | head -15"

Verify onboarding.py patches


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "sed -n '143,155p;745,758p' /opt/ligbox-wizard/frontend/src/App.jsx"

Read errorMessage and choosePortalDns catch


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "python3 << 'PY'
from pathlib import Path
p = Path('/opt/ligbox-wizard/frontend/src/App.jsx')
text = p.read_text()
# Fix broken errorMessage join
import re
text = re.sub(
    r\"return parts\.join\('\s*\n\s*'\)\",
    \"return parts.join('\\\\n\\\\n')\",
    text,
    count=1,
)
text = text.replace(
    \"\"\"    } catch (e) {
      setError(e.message)
      if (e.detail?.hint) setError(\`\${e.message}\\n\\n\${e.detail.hint}\`)
      setDnsChoice(null)\"\"\",
    \"\"\"    } catch (e) {
      setError(e.message)
      setDnsChoice(null)\"\"\",
)
p.write_text(text)
print('fixed errorMessage join')
PY
sed -n '143,155p' /opt/ligbox-wizard/frontend/src/App.jsx"

Fix broken errorMessage join in App.jsx


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 'python3 << "PY"
from pathlib import Path
p = Path("/opt/ligbox-wizard/frontend/src/App.jsx")
text = p.read_text()
start = text.index("function errorMessage")
end = text.index("function isBenignLogEntry")
new_fn = """function errorMessage(data, fallback) {
  const d = data?.detail
  if (typeof d === \"object\" && d?.message) {
    const parts = [d.message]
    if (d.hint && !String(d.message).includes(d.hint)) parts.push(d.hint)
    return parts.join(\"\\n\\n\")
  }
  if (typeof d === \"string\") return d
  return data?.message || fallback
}

"""
text = text[:start] + new_fn + text[end:]
# remove duplicate hint line in choosePortalDns
text = text.replace(
    "      setError(e.message)\n      if (e.detail?.hint) setError(`${e.message}\\n\\n${e.detail.hint}`)\n      setDnsChoice(null)",
    "      setError(e.message)\n      setDnsChoice(null)",
)
p.write_text(text)
print("ok")
PY
sed -n "143,155p" /opt/ligbox-wizard/frontend/src/App.jsx'

Rewrite errorMessage function correctly


[REDACTED]

Write

# Base de Conhecimento — Ligbox Ops

**Atualizado:** 2026-06-19
**Responsável:** Roger / equipe Desk

Índice de artigos operacionais (troubleshooting, erros comuns, procedimentos).

| ID | Artigo | Tema |
|----|--------|------|
| KB-001 | [Wizard — Cloudflare 1002 / domínio inválido](KB-001-WIZARD-CLOUDFLARE-DOMINIO-INVALIDO.md) | VM112 onboarding · DNS |
| — | [ASM handoff vs ticket fechado](../specs/010-desk-assist-takeover/ASM-HANDOFF-FLOW.md) | Spec 010 · assistência |

**Relacionado:** [`docs/anais-referencia/INDICE_ANAIS.md`](../anais-referencia/INDICE_ANAIS.md)

# KB-001 — Wizard: erro Cloudflare 1002 / «Invalid domain»

**Data:** 2026-06-19
**VM:** 112 (`onboard.ligbox.com.br`)
**Passo wizard:** DNS (Passo 2) ou Domínio (Passo 1)

---

## Sintoma

Cliente ou técnico vê:

```
Algo deu errado
[{'code': 1002, 'message': 'Invalid domain'}]
Token precisa «Account - Zone - Create» e «Zone - Edit».
```

Badge do domínio pode mostrar só **`betinplace`** (sem `.com`).

---

## Causa real (na maioria dos casos)

**Não é falta de permissão no token Cloudflare.**

A Cloudflare rejeita domínios **sem TLD** (formato FQDN):

| Domínio enviado | Resultado API |
|-----------------|---------------|
| `betinplace` | **1002 — Invalid domain** |
| `betinplace.com` | OK — zona existe ou é criada |

O wizard antigo mostrava sempre a dica genérica do **token**, mesmo quando o erro era **formato de domínio**.

---

## Como confirmar (técnico)

```bash
# Domínio inválido
curl -s -X POST 'http://127.0.0.1:8090/api/onboarding/dns/cloudflare/provision-zone' \
  -H 'Content-Type: application/json' -H 'X-Onboarding-Session: test' \
  -d '{"domain":"betinplace"}' | python3 -m json.tool

# Domínio válido
curl -s -X POST 'http://127.0.0.1:8090/api/onboarding/dns/cloudflare/provision-zone' \
  -H 'Content-Type: application/json' -H 'X-Onboarding-Session: test' \
  -d '{"domain":"betinplace.com"}' | python3 -m json.tool
```

Token activo: `/opt/ligbox-deploy/env/.env` → `CLOUDFLARE_API_TOKEN` (prefixo `cfut_` ou `cfat_`).

---

## Solução para o cliente

1. **Voltar** ao passo **Domínio**
2. Corrigir para formato completo: **`seudominio.com`** ou **`empresa.com.br`**
3. Avançar novamente para DNS

Ou sessão limpa:

`https://onboard.ligbox.com.br/onboard?domain=betinplace.com`

(Ctrl+Shift+R se `sessionStorage` tiver domínio antigo.)

---

## Diferenciar de ASM (assistência Desk)

| Erro ASM | Erro DNS 1002 |
|----------|---------------|
| «controle interno do suporte Ligbox Desk» | `Invalid domain` / código **1002** |
| Cliente pausado (overlay) | Wizard utilizável, passo DNS |
| Após handoff deve sumir | Persiste até corrigir domínio |

Ver: [`specs/010-desk-assist-takeover/ASM-HANDOFF-FLOW.md`](../../specs/010-desk-assist-takeover/ASM-HANDOFF-FLOW.md)

---

## Correção implementada (2026-06-19)

| Camada | Alteração |
|--------|-----------|
| **Passo 1 (frontend)** | Bloqueia avanço se domínio não tiver `.` — mensagem: *Use o formato seudominio.com* |
| **API validate-domain** | HTTP 400 com `invalid_domain_format` |
| **API provision-zone** | Código 1002 → mensagem clara (não dica de token) |
| **errorMessage (UI)** | Mostra `message` + `hint` do backend |

Ficheiros VM112:

- `backend/app/services/domain_format.py`
- `backend/app/routers/onboarding.py`
- `frontend/src/domainUtils.js` → `validatePrimaryDomain()`
- `frontend/src/App.jsx`

---

## Quando **é** problema de token

Só investigar token se:

- Domínio **já está** em formato FQDN (`empresa.com.br`)
- Erro menciona **permission**, **zone.create**, ou HTTP 403 da Cloudflare
- `cf.verify_token()` falha no backend

Permissões necessárias no token Ibytera: **Account - Zone - Create** + **Zone - Edit**.

---

## Mensagem esperada após fix (domínio sem TLD)

```
Domínio inválido. Use o formato completo, por exemplo seudominio.com ou empresa.com.br.

Indique o domínio com extensão (.com, .com.br, etc.) — não use só o nome (ex.: betinplace → betinplace.com).
```

"""Validação de domínio FQDN para onboarding (passo Domínio / DNS Cloudflare)."""

from __future__ import annotations

import re

FQDN_RE = re.compile(
    r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
)

INVALID_DOMAIN_MESSAGE = (
    "Domínio inválido. Use o formato completo, por exemplo seudominio.com ou empresa.com.br."
)
INVALID_DOMAIN_HINT = (
    "Indique o domínio com extensão (.com, .com.br, etc.) — "
    "não use só o nome (ex.: betinplace → betinplace.com)."
)

def normalize_domain(raw: str) -> str:
    return (raw or "").lower().strip().rstrip(".")

def is_valid_primary_domain(domain: str) -> bool:
    d = normalize_domain(domain)
    if not d or len(d) > 253 or "." not in d:
        return False
    return bool(FQDN_RE.match(d))

def validate_primary_domain(domain: str) -> None:
    if not is_valid_primary_domain(domain):
        raise ValueError(INVALID_DOMAIN_MESSAGE)

def invalid_domain_http_detail() -> dict:
    return {
        "code": "invalid_domain_format",
        "message": INVALID_DOMAIN_MESSAGE,
        "hint": INVALID_DOMAIN_HINT,
    }

def cloudflare_error_http_detail(exc: Exception, domain: str) -> dict:
    raw = str(exc)
    lower = raw.lower()
    if "1002" in raw and "invalid domain" in lower:
        return invalid_domain_http_detail()
    if not is_valid_primary_domain(domain):
        return invalid_domain_http_detail()
    if "zone.create" in lower or "permission" in lower or "zone - create" in lower:
        return {
            "code": "cloudflare_provision_failed",
            "message": raw,
            "hint": "Token precisa «Account - Zone - Create» e «Zone - Edit».",
        }
    return {
        "code": "cloudflare_provision_failed",
        "message": raw,
        "hint": "Verifique o domínio e o token Cloudflare Ibytera.",
    }

StrReplace

### 2026-06-19 — Base de conhecimento · Wizard DNS 1002

| Ficheiro | Descrição |
|----------|-----------|
| `docs/base-conhecimento/README.md` | Índice KB |
| `docs/base-conhecimento/KB-001-WIZARD-CLOUDFLARE-DOMINIO-INVALIDO.md` | Erro 1002 / domínio sem TLD vs token |
| `deploy/vm112-wizard-domain/domain_format.py` | Validação FQDN + mensagens claras |

**Regra:** domínio no wizard deve ser FQDN (`seudominio.com`) — `betinplace` sem `.com` gera Cloudflare 1002.

---

## Entradas anteriores (chat bruto)


[REDACTED]

Shell

sshpass -p '@betinplace' ssh root@10.10.10.112 "cd /opt/ligbox-wizard/frontend && npm run build:wizard 2>&1 | tail -8 && systemctl restart ligbox-wizard && sleep 2 && systemctl is-active ligbox-wizard"

Build wizard and restart service on VM112

sshpass -p '@betinplace' ssh root@10.10.10.112 "echo '=== validate-domain betinplace ==='; curl -s -X POST 'http://127.0.0.1:8090/api/onboarding/validate-domain' -H 'Content-Type: application/json' -H 'X-Onboarding-Session: test-kb' -d '{\"domain\":\"betinplace\"}' | python3 -m json.tool; echo '=== provision betinplace ==='; curl -s -X POST 'http://127.0.0.1:8090/api/onboarding/dns/cloudflare/provision-zone' -H 'Content-Type: application/json' -H 'X-Onboarding-Session: test-kb' -d '{\"domain\":\"betinplace\"}' | python3 -m json.tool"

Test API returns clear domain error messages


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "sed -n '213,225p;375,395p;440,455p' /opt/ligbox-wizard/backend/app/routers/onboarding.py; ls -la /opt/ligbox-wizard/backend/app/services/domain_format.py"

Verify onboarding.py validation code on VM112


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "/opt/ligbox-wizard/.venv/bin/python3 -c \"
import sys; sys.path.insert(0,'/opt/ligbox-wizard/backend')
from app.services.domain_format import is_valid_primary_domain, cloudflare_error_http_detail
from app.services.cloudflare import CloudflareError
print('betinplace', is_valid_primary_domain('betinplace'))
print('betinplace.com', is_valid_primary_domain('betinplace.com'))
e = CloudflareError(\\\"[{'code': 1002, 'message': 'Invalid domain'}]\\\")
print(cloudflare_error_http_detail(e, 'betinplace'))
\"; systemctl restart ligbox-wizard; sleep 3; curl -s -X POST 'http://127.0.0.1:8090/api/onboarding/validate-domain' -H 'Content-Type: application/json' -H 'X-Onboarding-Session: test-kb2' -d '{\"domain\":\"betinplace\"}' | python3 -m json.tool"

Test domain_format module and restart API


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "curl -s -X POST 'http://127.0.0.1:8090/api/onboarding/dns/cloudflare/provision-zone' -H 'Content-Type: application/json' -H 'X-Onboarding-Session: test-kb2' -d '{\"domain\":\"betinplace\"}' | python3 -m json.tool"

Test provision-zone returns 400 clear message


Roger, feito — mensagem clara no wizard, validação antecipada e entrada na base de conhecimento.

## O que mudou no wizard (VM112 — já em produção)

### Passo 1 — Domínio
Se digitar só `betinplace` (sem `.com`), **não avança** e mostra:

> **Domínio inválido. Use o formato completo, por exemplo seudominio.com ou empresa.com.br.**

### Passo 2 — DNS (Cloudflare)
Mesma validação na API — **antes** de chamar a Cloudflare. Não aparece mais a dica enganosa do token quando o problema é formato.

Mensagem completa:

```
Domínio inválido. Use o formato completo, por exemplo seudominio.com ou empresa.com.br.

Indique o domínio com extensão (.com, .com.br, etc.) —
não use só o nome (ex.: betinplace → betinplace.com).
```

A dica do **token** só aparece quando o erro for **realmente** de permissão Cloudflare.

---

## Base de conhecimento

| Ficheiro | Conteúdo |
|----------|----------|
| `docs/base-conhecimento/README.md` | Índice KB |
| `docs/base-conhecimento/KB-001-WIZARD-CLOUDFLARE-DOMINIO-INVALIDO.md` | Artigo completo (sintoma, causa, solução, testes) |

Também referenciado em `docs/anais-referencia/INDICE_ANAIS.md`.

---

## Para testar agora

1. Ctrl+Shift+R em `onboard.ligbox.com.br`
2. Voltar ao passo **Domínio**
3. Usar **`betinplace.com`**
4. Avançar para DNS

Se quiser, no próximo passo posso mostrar essa mensagem também **debaixo do campo** domínio enquanto digita (validação em tempo real).

[REDACTED]


[Image]
<user_query>
depois eu testo , sei que vc fez legal , mas temos uma seituacao a corrigir : toda vez que este modal do abrir ticket de suporte no side clinete e acuionado , ele mostra o botao acompanhar chamado . qual foi a estrutura que vc esqueveru na spec para a funcao de ste botao ? Vc opide me explicar ?
</user_query>


Vou localizar no código e na spec o que define o botão «Acompanhar chamado» e quando ele deve aparecer.

[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "grep -rn 'Acompanhar\|acompanhar\|support/ticket\|WizardSupport\|wizardSupport' /opt/ligbox-wizard/frontend/src --include='*.jsx' --include='*.js' 2>/dev/null | head -40"

Find Acompanhar chamado button in wizard frontend


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "cat /opt/ligbox-wizard/frontend/src/WizardSupportModal.jsx"

Read full WizardSupportModal component


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "grep -rn 'support_url\|ticket_id\|layman' /opt/ligbox-wizard/backend/app --include='*.py' | head -40"

Find support_url in wizard backend


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "cat /opt/ligbox-wizard/backend/app/services/support_tickets.py"

Read full support_tickets service

sshpass -p '@betinplace' ssh root@10.10.10.112 "grep -rn 'supportView\|readSessionIdFromUrl\|support.*ticket\|/support' /opt/ligbox-wizard/frontend/src --include='*.jsx' --include='*.js' | head -35"

Find client support tracking view in wizard


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "grep -rn 'ticket\|supportView\|SupportTicket\|readTicket' /opt/ligbox-wizard/frontend/src/App.jsx /opt/ligbox-wizard/frontend/src/sessionPersist.js | head -50"

Find ticket URL param and supportView logic


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "sed -n '1295,1330p;850,930p' /opt/ligbox-wizard/frontend/src/App.jsx; grep -n 'support/ticket\|SupportTicket' /opt/ligbox-wizard/backend/app/routers/onboarding.py"

Read supportView UI and support ticket API routes


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "sed -n '54,180p' /opt/ligbox-wizard/backend/app/routers/onboarding.py; cat /opt/ligbox-wizard/frontend/src/sessionPersist.js"

Read support ticket GET endpoint and sessionPersist

sshpass -p '@betinplace' ssh root@10.10.10.112 "find /opt/ligbox-wizard/frontend/src -name '*Support*' -o -name '*Ticket*' 2>/dev/null"

Find support/ticket related frontend components


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "grep -n 'ticketResult\|wizard_ticket\|OB-\|existing.*ticket' /opt/ligbox-wizard/frontend/src/WizardSupportModal.jsx /opt/ligbox-wizard/frontend/src/App.jsx"

Check if existing ticket is remembered across modal opens


Roger, boa pergunta — o botão **existe no código**, mas a **spec da função dele nunca foi fechada**. Por isso ele aparece sempre após abrir chamado, sem comportamento claro para o cliente.

## O que o botão faz hoje (implementado)

No `WizardSupportModal.jsx`, após criar o chamado:

```javascript
{ticketResult.support_url && (
  <button onClick={handleOpenSupportLink}>Acompanhar chamado →</button>
)}
```

A API **sempre** devolve `support_url`:

```
https://onboard.ligbox.com.br/onboard?session={uuid}&ticket=OB-F21B62F4
```

Ao clicar, abre **nova aba do wizard** com essa URL — não é uma página de acompanhamento.

---

## O que falta no frontend (buraco da spec)

| Previsto no código | Implementado? |
|--------------------|---------------|
| `?session=` → modo técnico «Modo suporte» + terminal | Parcial (`supportView`) |
| `?ticket=OB-…` → vista de acompanhamento do chamado | **Não** — parâmetro ignorado |
| `GET /support/ticket/{id}` → status, mensagens, infra | API existe, **UI cliente não usa** |
| Fase 2 «agente KIMI» + `agent_messages` | Comentário no backend, **sem UI** |
| Ligação OB- ↔ ticket Desk `#45` | Só no Desk (webhook); **cliente não vê** |

Ou seja: o botão promete «acompanhar», mas só reabre o wizard — o cliente não vê status do chamado, resposta do suporte nem progresso.

---

## O que estava na spec (e o que não estava)

### Coberto noutras specs

| Spec | O que diz |
|------|-----------|
| **012** | Ticket Desk na «Criar conta» — jornada ops, não portal cliente |
| **010** | Escalada ASM no Desk — lado técnico |
| **011 (stub)** | OTRS futuro — não é isto |

### Só no código (sem spec formal)

No `support_tickets.py`:

> *«fase 1: registo + contexto; fase 2: agente KIMI»*

**Fase 1** (entregue):

- Criar `OB-XXXXXXXX`
- Guardar JSON em `/var/lib/ibytera-mail-portal/tickets/`
- Texto «layman» (por que o webmail não abre)
- E-mail ops + webhook `onboarding.escalated` → Desk
- Gerar `support_url`

**Fase 2** (nunca especificada para o cliente):

- Vista **«Acompanhar chamado»** dedicada
- Ler `?ticket=OB-…` na URL
- Poll `GET /support/ticket/{id}` (status, pendências infra, mensagens)
- Mostrar updates do Desk / agente
- Regras de quando mostrar o botão

---

## Por que o botão aparece «sempre»

1. **`support_url` é sempre preenchido** após POST — condição do botão é sempre verdadeira.
2. **Não há spec** de visibilidade condicional, por exemplo:
   - só se fase 2 existir;
   - só se ticket `open`;
   - esconder se já existe OB- na sessão;
   - trocar por «Copiar link» até a vista existir.
3. Ao **fechar e reabrir** o modal, o estado zera — mas cada novo «Abrir chamado» gera **outro OB-** (sem idempotência na spec).

---

## Estrutura que faltou na spec (resumo)

```
Cliente abre chamado (modal)
        ↓
   OB-XXXX (VM112)  ──webhook──►  Ticket Desk #N
        ↓                              ↓
  support_url                    técnico vê tudo
        ↓
  [FALTA NA SPEC]
  Vista cliente /onboard?ticket=OB-XXXX
    · status: open | em análise | resolvido
    · checklist infra (LE, DNS, Traefik…)
    · mensagens suporte / agente
    · link «continuar wizard» (session)
        ↓
  Botão «Acompanhar chamado» → essa vista (não wizard cru)
```

**Regras UX que faltaram documentar:**

| Regra | Estado |
|-------|--------|
| Botão só após fase 2 da vista existir | Não |
| `?ticket=` abre painel de status, não passo DNS | Não |
| Um OB- por sessão (reabrir modal = retomar, não duplicar) | Não |
| Cliente vê updates quando Desk muda ticket | Não |
| Diferenciar link técnico (`?session=`) vs cliente (`?ticket=`) | Não |

---

## Conclusão

Não «esqueci uma linha» numa spec existente — **a Spec do portal cliente «Acompanhar chamado» (fase 2) nunca foi escrita**. Entregámos fase 1 (criar chamado + notificar Desk) e deixámos o botão apontando para um destino ainda não construído.

Quando quiseres corrigir, o caminho natural é:

1. **Spec 026** — Portal cliente OB- (vista `?ticket=`, estados, poll API, ligação Desk).
2. **Ajuste rápido UX** — esconder «Acompanhar chamado» até a vista existir, ou renomear para «Abrir link da sessão» (honesto com o que faz hoje).

Queres que eu escreva a **Spec 026** e/ou faça o ajuste rápido no modal?

[REDACTED]


<user_query>
escreva a pec 026 — Portal cliente OB- (vista ?ticket=, estados, poll API, ligação Desk).  so nao sei se sera a 026 ( acho que ja existe) e principalmente crie a UX de expetiencia do usuario - quais sas propostas de acompanemneto que vc entende ser ideial e completa para deixar o cliente susuario satisfeito ?
</user_query>


Verificando numeração das specs existentes e o fluxo OB-/Desk para redigir a spec com propostas de UX completas.

[REDACTED]


026 está livre (há 025 e 027). Redigindo a spec completa com proposta de UX para o portal cliente OB-.

[REDACTED]


[REDACTED]

Write

# Spec 026 — Portal cliente OB- (acompanhamento de chamado)

**Criado:** 2026-06-19
**Solicitado por:** Roger
**Status:** 📋 **Draft — UX + arquitectura aprováveis**
**Prioridade:** **P0** (gap UX pós-chamado wizard)
**Sistema:** Wizard VM112 (`onboard.ligbox.com.br`) + Desk VM122 (espelho ops)

**Depende de:** Spec 010 (escalada Desk), Spec 012 (ticket jornada), Spec 025 (continuidade wizard)
**Relacionado:** Spec 007 (push futuro), Spec 011 (OTRS — não substituir isto)

**UX detalhada:** [`UX-CLIENTE-ACOMPANHAMENTO.md`](UX-CLIENTE-ACOMPANHAMENTO.md)

---

## Problema

A **Fase 1** do chamado wizard (`support_tickets.py`) está entregue:

- Cliente abre chamado → `OB-XXXXXXXX`
- JSON local VM112 + e-mail ops + webhook `onboarding.escalated` → Desk
- Modal mostra sucesso + botão **«Acompanhar chamado →»**

A **Fase 2** (portal cliente) **nunca foi especificada**. O botão abre:

```
/onboard?session={uuid}&ticket=OB-XXXX
```

…mas o frontend **ignora `?ticket=`** e reabre o wizard cru. O cliente não vê status, progresso nem resposta do suporte — só promessa vazia.

**Impacto:** insatisfação, chamados duplicados, confiança baixa («abri chamado e nada mudou»).

---

## Objetivo

Entregar uma **experiência de acompanhamento completa e honesta** para o utilizador final:

1. Saber **o que aconteceu** (chamado registado, número OB-).
2. Saber **o que falta** (DNS, webmail, certificado) em linguagem simples.
3. Saber **quem está a tratar** e **qual o próximo passo** (Ligbox vs cliente).
4. **Continuar o wizard** quando fizer sentido — sem perder contexto.
5. **Não duplicar** chamados na mesma sessão.

O Desk continua **control plane** (Spec 010). O portal OB- é a **face pública**; o ticket Desk `#N` fica interno.

---

## Princípios UX (Roger)

| # | Princípio | Regra |
|---|-----------|--------|
| P1 | **Linguagem humana** | Zero jargão (`Traefik`, `LE`, `zmprov`) na vista cliente — só no terminal opcional «detalhes técnicos». |
| P2 | **Uma verdade** | Checklist infra = mesma fonte que wizard (`infrastructure.get_status`) — não inventar status. |
| P3 | **Expectativa clara** | Sempre mostrar: *quem age agora* (cliente / Ligbox / «ninguém — aguardar DNS»). |
| P4 | **Link bookmarkável** | URL com `ticket` + `session` funciona sem login (par chave — ver Segurança). |
| P5 | **Honestidade** | Botão «Acompanhar chamado» só após existir vista Fase A; senão «Copiar link do chamado». |
| P6 | **Não competir com ASM** | Se Spec 010 ASM activo → portal mostra «Suporte a tratar» — não erro 423 nem wizard bloqueado confuso. |

---

## Arquitectura — dois IDs, uma jornada

```mermaid
flowchart LR
  subgraph Cliente["Cliente (browser)"]
    M[Modal Ajuda]
    P[Portal OB- ?ticket=]
    W[Wizard /onboard]
  end
  subgraph VM112["VM112"]
    OB[(OB- JSON)]
    API[API pública ticket]
  end
  subgraph VM122["Desk VM122"]
    DT[(Ticket #N SQLite)]
  end
  M -->|POST /support/ticket| OB
  OB -->|webhook escalated| DT
  P -->|GET /support/ticket/public| OB
  DT -.->|sync status| OB
  P -->|Continuar configuração| W
  W -->|session_id| OB
```

| Identificador | Quem vê | Função |
|---------------|---------|--------|
| `OB-XXXXXXXX` | **Cliente** | Referência pública, link, e-mail |
| `session_id` (UUID) | Cliente + suporte | Correlação terminal / telemetria |
| Desk `#N` | **Só ops** | Console assistência, ASM, audit |

---

## Estados do chamado OB- (cliente)

Estados **visíveis** (badge + copy):

| Estado cliente | `status` interno | Significado |
|----------------|------------------|-------------|
| **Recebido** | `open` | Chamado criado; fila ops |
| **Em análise** | `in_review` | Técnico/assistente a trabalhar |
| **Aguardando você** | `waiting_client` | Acción do cliente (ex.: NS no registrador) |
| **Em tratamento Ligbox** | `in_progress` | Ops a aplicar DNS/infra |
| **Resolvido** | `resolved` | Webmail/infra OK ou encerrado com sucesso |
| **Encerrado** | `closed` | Sem acção pendente (pode reabrir) |

Transições:

```mermaid
stateDiagram-v2
    [*] --> Recebido: cliente abre chamado
    Recebido --> EmAnalise: ops assume / webhook desk
    EmAnalise --> AguardandoVoce: instrução NS/DNS
    EmAnalise --> EmTratamento: ops actua infra
    AguardandoVoce --> EmTratamento: cliente confirma / DNS OK
    EmTratamento --> Resolvido: infra OK
    Resolvido --> Encerrado: auto ou ops fecha
    Encerrado --> Recebido: reabertura (novo ciclo ou mesmo OB-)
```

**Regra:** estado cliente derivado de `status` OB- + `assist_mode` Desk + checklist infra — ver [`UX-CLIENTE-ACOMPANHAMENTO.md`](UX-CLIENTE-ACOMPANHAMENTO.md).

---

## Rotas e URLs

| Rota | Quem | Descrição |
|------|------|-----------|
| `/onboard` | Cliente | Wizard normal |
| `/onboard?session=&ticket=OB-` | Cliente | **Portal acompanhamento** (Fase A) — **não** wizard completo por defeito |
| `/onboard?session=` (sem ticket) | Suporte | Modo espelho terminal (já existe `supportView`) |
| `/assist/{session}?desk=1` | Técnico | ASM Spec 010 |

**Decisão URL (MVP):** mesmo host `/onboard`, ramificação por query `ticket=` — evita novo certificado/path Traefik. Fase B opcional: `/chamado/OB-XXXX` pretty URL redirect.

---

## API VM112 (nova superfície pública)

### `GET /api/onboarding/support/ticket/{ticket_id}/public`

Query obrigatória: `session_id` (must match ticket) — **par chave**.

Resposta (sem PII ops, sem tokens):

```json
{
  "ticket_id": "OB-F21B62F4",
  "status": "in_progress",
  "status_label": "Em tratamento Ligbox",
  "owner": "ligbox",
  "domain": "betinplace.com",
  "account_email": "admin@betinplace.com",
  "created_at": "2026-06-19T12:00:00Z",
  "updated_at": "2026-06-19T14:30:00Z",
  "client_note": "teste roger",
  "layman": { "title": "...", "paragraphs": ["..."] },
  "checklist": [
    { "id": "carbonio_domain", "label": "Conta de e-mail criada", "ok": true },
    { "id": "dns_mx", "label": "DNS para receber e-mails", "ok": false, "hint": "..." }
  ],
  "timeline": [
    { "at": "...", "kind": "milestone", "title": "Chamado registrado", "body": "..." },
    { "at": "...", "kind": "update", "title": "Equipe verificando DNS", "body": "..." }
  ],
  "actions": {
    "can_continue_wizard": true,
    "can_add_note": true,
    "can_reopen": false,
    "wizard_url": "/onboard?session=...&step=dns"
  },
  "assist": {
    "active": false,
    "message": null
  }
}
```

### `POST /api/onboarding/support/ticket/{ticket_id}/note`

Body: `{ "session_id", "message" }` — nota do cliente (append timeline).

### `POST /api/onboarding/support/ticket` (alteração)

- **Idempotente** por `(session_id, domain)` se já existir OB- `open|in_*|waiting_*` → devolver existente, **não** criar duplicado.
- Resposta inclui `tracking_url` (alias `support_url`).

### Sync Desk → OB- (VM122 → VM112 ou poll)

| Evento Desk | Efeito OB- |
|-------------|------------|
| Técnico assume ASM | `assist.active=true`, timeline «Suporte Ligbox a tratar» |
| Handoff | `assist.active=false`, «Pode continuar a configuração» |
| Nota pública ops* | Entrada timeline cliente |
| Ticket `resolved` | OB- → `resolved` |

\* *Nota pública:* flag `client_visible` em acções Desk (Fase B).

**MVP sync:** VM112 poll Desk `GET /api/v1/assist/sessions/{session_id}` com token serviço **ou** webhook reverso `desk.ticket.updated` (preferível Fase B).

---

## Componentes frontend (VM112)

| Componente | Função |
|------------|--------|
| `WizardSupportModal.jsx` | Abrir chamado; pós-sucesso → CTA «Acompanhar chamado» |
| `ClientTicketPortal.jsx` | **Novo** — vista `?ticket=` |
| `ClientTicketTimeline.jsx` | Feed filtrado |
| `ClientInfraChecklist.jsx` | Checklist layman |
| `App.jsx` | Router: `ticket` query → portal vs wizard |

### Lógica entrada `App.jsx`

```
if (ticketFromUrl && sessionFromUrl) → <ClientTicketPortal />
else if (sessionFromUrl && !ticketFromUrl) → wizard + supportView banner
else → wizard normal
```

### Modal — regras botão

| Situação | Botão |
|----------|--------|
| Chamado acabou de ser criado | **Acompanhar chamado →** (abre portal mesma aba ou nova) |
| Portal Fase A não deployado | **Copiar link do chamado** (temporário) |
| OB- já existia (idempotente) | **Ver andamento do chamado →** |

---

## Ligação Desk

| Campo OB- JSON | Campo Desk |
|----------------|------------|
| `desk_ticket_id` | `tickets.id` (preencher após webhook) |
| `wizard_ticket_id` | payload `wizard_ticket_id` (já existe) |
| `session_id` | `tickets.session_id` |

Desk **não** expõe `#N` ao cliente. Técnico vê OB- no painel ticket (já implementado).

---

## Segurança

| Risco | Mitigação MVP | Fase B |
|-------|---------------|--------|
| Guess `OB-` (8 hex) | Exigir `session_id` matching | HMAC token no link + TTL |
| Scraping timeline | Rate limit IP | CAPTCHA se abuso |
| PII em timeline | Filtro `client_visible` | Revisão automática |

**Regra:** resposta pública **nunca** inclui: senhas, tokens ASM, IPs internos, nomes de VM, credenciais Cloudflare.

---

## Fases de entrega

### Fase A — MVP portal (P0)

- [ ] `ClientTicketPortal` + routing `?ticket=`
- [ ] API `GET .../public` + checklist + timeline mínima
- [ ] Idempotência `POST /support/ticket`
- [ ] Modal: botão honesto + link bookmarkável
- [ ] Poll 30s no portal (ou SSE Fase B)
- [ ] Integração ASM: banner «Suporte a tratar»

### Fase B — Comunicação bidireccional (P1)

- [ ] Cliente adiciona nota
- [ ] Desk push updates → OB- timeline
- [ ] E-mail «seu chamado foi atualizado» (Spec 007)
- [ ] Pretty URL `/chamado/OB-XXXX`

### Fase C — Agente assistente (P2)

- [ ] `agent_messages` (KIMI) com respostas layman
- [ ] Sugestões automáticas («faltam NS X e Y»)
- [ ] Chat widget no portal (opcional)

---

## Critérios de aceite MVP (Fase A)

1. Cliente abre chamado → clica «Acompanhar» → vê **portal dedicado**, não wizard passo DNS.
2. Portal mostra: número OB-, estado, checklist infra, «quem age agora».
3. «Continuar configuração» leva ao passo correcto do wizard (`session` preservada).
4. Segundo «Abrir chamado» na mesma sessão → **mesmo OB-**, sem duplicar.
5. Com ASM activo, portal indica suporte activo (Spec 010).
6. Técnico no Desk vê mesmo OB- e session_id (já OK).
7. pt-BR em todos os labels.

---

## Fora de escopo

- Portal login separado (conta Ligbox) — usar par session+ticket
- Substituir Desk por OTRS (Spec 011)
- Cliente ver consola técnica completa (opcional accordion «detalhes»)

---

## Referências código actual

| Área | Path VM112 |
|------|------------|
| Tickets OB- | `backend/app/services/support_tickets.py` |
| API | `backend/app/routers/onboarding.py` |
| Modal | `frontend/src/WizardSupportModal.jsx` |
| Session URL | `frontend/src/sessionPersist.js` |

---

## Documentos

| Ficheiro | Conteúdo |
|----------|----------|
| [`UX-CLIENTE-ACOMPANHAMENTO.md`](UX-CLIENTE-ACOMPANHAMENTO.md) | Wireframes, copy, jornadas, proposta UX completa |
| [`tasks.md`](tasks.md) | Checklist implementação |

---

*Spec 026 · Roger · Ligbox Ops · 2026-06-19*

# UX — Acompanhamento de chamado OB- (cliente)

**Spec 026 · Anexo UX**
**Roger · 2026-06-19**

Este documento descreve a **experiência ideal** para o utilizador final — o que o deixa informado, confiante e no controlo, sem sobrecarregar com técnica.

---

## Persona

**Maria** — dona de PME, configurou e-mail corporativo no wizard. Conta foi criada mas `mail.empresa.com` ainda não abre. Abriu chamado OB- porque ficou insegura.

**Necessidades emocionais:**

- «Alguém viu o meu pedido?»
- «É normal demorar?»
- «O que eu preciso fazer vs o que vocês fazem?»
- «Posso continuar amanhã sem perder tudo?»

---

## Proposta UX — visão em 3 camadas

A satisfação vem de **três ecrãs mentais** (podem ser uma página com secções):

```
┌─────────────────────────────────────────────────────────┐
│  CAMADA 1 — STATUS (5 segundos para entender)           │
│  OB-F21B62F4 · Em tratamento Ligbox · betinplace.com    │
│  [████████░░] 70% — falta publicar webmail na internet  │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│  CAMADA 2 — O QUE FALTA (checklist humana)              │
│  ✓ Conta de e-mail criada                               │
│  ✓ Domínio registado no servidor                        │
│  ⏳ DNS apontado corretamente  ← você ou registrador     │
│  ⏳ Webmail acessível na internet  ← Ligbox             │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│  CAMADA 3 — LINHA DO TEMPO + PRÓXIMO PASSO              │
│  14:02 Chamado registrado                               │
│  14:15 Equipe Ligbox a verificar DNS                    │
│  ── Próximo: aguardamos nameservers (até 24h) ──        │
│  [Continuar configuração]  [Adicionar informação]       │
└─────────────────────────────────────────────────────────┘
```

---

## Ecrã 1 — Portal «Acompanhar chamado» (principal)

**URL:** `/onboard?session=…&ticket=OB-…`
**Título página:** `Seu chamado · Ligbox`

### Header fixo

| Elemento | Copy exemplo |
|----------|--------------|
| Logo / marca | Ligbox |
| Número | **Chamado OB-F21B62F4** |
| Domínio | betinplace.com |
| Badge estado | `Em tratamento Ligbox` (cor âmbar) |

### Bloco «Resumo em uma frase»

> Sua conta **admin@betinplace.com** já existe. Estamos a publicar o webmail na internet — isso depende do DNS e pode levar até **24–48 horas** após os nameservers estarem correctos.

*(Fonte: `layman.paragraphs` — já existe no backend.)*

### Bloco «Quem faz o quê agora»

| `owner` | UI |
|---------|-----|
| `ligbox` | Ícone equipe + «A equipe Ligbox está a tratar deste pedido. Você não precisa fazer nada agora.» |
| `client` | Ícone utilizador + «Precisamos da sua acção: confirmar nameservers no registrador do domínio.» + botão «Ver instruções DNS» |
| `waiting_dns` | Relógio + «Aguardando propagação DNS na internet (automático após NS correctos).» |
| `none_done` | Check verde + «Tudo concluído — pode aceder ao webmail.» |

### Checklist (sempre visível)

Mapeamento infra → linguagem cliente:

| ID técnico | Label cliente |
|------------|---------------|
| carbonio_domain | Conta de e-mail criada no servidor |
| carbonio_account | Utilizador administrador activo |
| dns_ns | Domínio apontado para Cloudflare Ligbox |
| dns_mx | E-mails a chegar (registo MX) |
| dns_spf | Protecção anti-spam (SPF) |
| traefik_mail | Webmail acessível em mail.{domínio} |
| cert_le | Certificado de segurança (cadeado HTTPS) |

Cada item:

- ✓ verde + «Concluído»
- ⏳ âmbar + «Em progresso» + hint curto
- ○ cinza + «Pendente»

**Nunca** mostrar «CT114», «VM112», «zmprov».

### Linha do tempo (feed)

Eventos **client-safe** (ordem cronológica, mais recente em cima):

| Tipo | Exemplo |
|------|---------|
| `milestone` | Chamado registrado |
| `update` | Equipe Ligbox iniciou verificação DNS |
| `instruction` | Altere os nameservers para: norah.ns… |
| `success` | Webmail disponível — pode testar login |
| `client_note` | Você: «mudei NS ontem à noite» |

**Ocultar:** logs raw, erros API, IDs internos Desk.

### Acções (rodapé sticky mobile)

| Botão | Quando | Acção |
|-------|--------|-------|
| **Continuar configuração →** | `can_continue_wizard` | Wizard no passo correcto |
| **Adicionar informação** | sempre (Fase B MVP: Fase A só link) | Modal textarea |
| **Copiar link deste chamado** | sempre | clipboard |
| **Abrir webmail** | checklist mail OK | `https://mail.{domain}` |

### ASM activo (Spec 010)

Overlay suave **no portal** (não substituir página):

```
┌────────────────────────────────────────┐
│ Suporte Ligbox activo                  │
│ Um técnico está a tratar desta         │
│ configuração. Pode acompanhar aqui;    │
│ o assistente wizard está pausado.      │
└────────────────────────────────────────┘
```

---

## Ecrã 2 — Modal «Abrir chamado» (ajustes)

### Antes de criar

Sem mudança — resumo sessão + nota opcional.

### Depois de criar (sucesso)

**Ordem visual:**

1. ✓ Chamado registrado — **OB-XXXX**
2. Parágrafo layman (já existe)
3. Caixa «O que acontece agora» (3 passos numerados):

   1. Recebemos o histórico da sua sessão (feito)
   2. A equipe analisa em até **X horas úteis** (SLA configurável)
   3. Acompanhe por este link — guardamos para si

4. Botões:
   - **Acompanhar chamado →** (primário) → portal mesma janela
   - **Fechar** (secundário) → volta wizard; banner discreto «Chamado OB-XXXX aberto · [Acompanhar]`

### Banner persistente no wizard (se OB- aberto)

Enquanto ticket não `resolved|closed`:

```
ℹ Chamado OB-F21B62F4 em andamento · [Ver andamento]
```

Evita cliente esquecer que já abriu suporte.

---

## Ecrã 3 — Estados especiais

### Tudo resolvido

```
┌────────────────────────────────────────┐
│ ✓ Chamado concluído                    │
│ Seu webmail está pronto.               │
│ [Abrir webmail] [Fechar chamado]       │
└────────────────────────────────────────┘
```

### Aguardando cliente (NS)

Mostrar **instruções DNS simplificadas** (não modal técnico completo):

- Nameservers a copiar (botões copiar)
- Link «Sou técnico — DNS noutro sítio»
- «Já alterei» → POST note + muda expectativa timeline

### Chamado duplicado evitado

Modal ao tentar abrir segundo chamado:

> Já existe um chamado aberto para esta sessão: **OB-XXXX**.
> [Ver andamento] [Contactar suporte por e-mail]

---

## Jornadas completas

### J1 — Webmail pendente (caso Maria / betinplace)

1. Wizard passo 4 — infra pendente
2. «Precisa de ajuda?» → Abrir chamado
3. Portal OB- — vê checklist + «Ligbox a tratar DNS/certificado»
4. Poll 30s — item «Webmail acessível» passa a ✓
5. Notificação in-page: «Seu webmail está pronto!»
6. CTA Abrir webmail

**Satisfação:** progresso visível sem ligar ao suporte.

### J2 — Cliente precisa mudar NS

1. Abre chamado
2. Portal → `owner=client`
3. Instruções NS + vídeo/link ajuda registrador (futuro)
4. Cliente clica «Já alterei nameservers»
5. Estado → `waiting_dns` + copy «Propagação 24–48h»
6. Quando verify OK → timeline «DNS confirmado»

### J3 — Técnico assume ASM (Desk)

1. Cliente tinha portal OB- aberto
2. Desk → Assumir sessão
3. Portal poll detecta `assist.active`
4. Banner «Suporte activo» — **sem** página de erro
5. Handoff → banner some + «Pode continuar configuração»

### J4 — Retorno dias depois

1. Cliente guarda link e-mail/bookmark
2. Abre `/onboard?session=&ticket=OB-`
3. Portal restaura estado actual (não passo 0 wizard)
4. «Continuar configuração» se ainda incompleto

---

## Microcopy — tom de voz

| Evitar | Preferir |
|--------|----------|
| Ticket escalado | Pedido recebido pela equipe |
| Infra sync failed | Ainda estamos a activar o webmail |
| HTTP 502 | Servidor ocupado — tentamos de novo em instantes |
| Invalid domain | Use o formato empresa.com.br |
| ASM / Desk | Suporte Ligbox |

---

## Satisfação — checklist product

O cliente está **satisfeito** quando consegue responder sim a:

| Pergunta | Onde na UX |
|----------|------------|
| O meu pedido foi registado? | OB- + timeline «Chamado registrado» |
| Sabem qual é o meu problema? | Layman + nota que escrevi |
| O que falta exactamente? | Checklist + barra progresso |
| Quem actua agora? | Bloco «Quem faz o quê» |
| Quanto tempo demora? | SLA + copy propagação DNS |
| Posso fazer algo útil? | CTA instruções / continuar wizard |
| Posso voltar depois? | Link bookmarkável + banner wizard |
| Fui ouvido se escrevi nota? | Timeline «Você: …» |

---

## Métricas sugeridas (Fase B)

| Métrica | Meta |
|---------|------|
| % clientes que abrem portal após chamado | > 60% |
| Tempo médio até primeira visualização portal | < 2 min |
| Chamados duplicados / sessão | < 5% |
| NPS pós-resolução (1 pergunta no portal) | baseline TBD |

---

## Wireframe ASCII — portal mobile

```
┌──────────────────────────┐
│ Ligbox    Chamado OB-…   │
├──────────────────────────┤
│ ● Em tratamento Ligbox   │
│ betinplace.com           │
├──────────────────────────┤
│ Sua conta já existe.     │
│ Estamos a publicar o     │
│ webmail…                 │
├──────────────────────────┤
│ Quem actua: Equipe Ligbox│
├──────────────────────────┤
│ Progresso                │
│ ✓ Conta criada           │
│ ✓ Servidor               │
│ ⏳ DNS                   │
│ ○ Webmail online         │
├──────────────────────────┤
│ Histórico                │
│ · 14:02 Registrado       │
│ · 14:15 Verificando DNS  │
├──────────────────────────┤
│ [Continuar configuração] │
│ [Copiar link]            │
└──────────────────────────┘
```

---

## Prioridade implementação UX

| Ordem | Entrega | Impacto satisfação |
|-------|---------|-------------------|
| 1 | Portal dedicado `?ticket=` | **Crítico** — deixa de mentir o botão |
| 2 | Checklist + owner | **Alto** — clareza |
| 3 | Timeline mínima | **Alto** — prova actividade |
| 4 | Banner wizard OB- aberto | **Médio** — continuidade |
| 5 | Idempotência chamado | **Médio** — evita confusão |
| 6 | ASM banner portal | **Médio** — confiança Spec 010 |
| 7 | Nota cliente + e-mail update | **Médio-alto** — Fase B |
| 8 | Agente KIMI | **Nice** — Fase C |

---

*Anexo UX Spec 026 · Roger · 2026-06-19*

# Tasks — Spec 026 Portal cliente OB-

**Prioridade:** P0 (Fase A)
**Estado:** 📋 Não iniciado

---

## Fase A — MVP portal (P0)

### Backend VM112

- [ ] **T026-01** Modelo OB-: campos `desk_ticket_id`, `owner`, `status` alargado (`in_review`, `waiting_client`, …)
- [ ] **T026-02** `GET /support/ticket/{id}/public?session_id=` — resposta Spec 026
- [ ] **T026-03** Mapear `infrastructure.get_status` → `checklist[]` layman
- [ ] **T026-04** Gerar `timeline[]` mínima (created, escalated webhook, infra changes)
- [ ] **T026-05** `POST /support/ticket` idempotente por `(session_id, domain)`
- [ ] **T026-06** Preencher `desk_ticket_id` após webhook Desk (callback ou poll)

### Frontend VM112

- [ ] **T026-10** `ClientTicketPortal.jsx` — layout 3 camadas UX
- [ ] **T026-11** Routing `App.jsx`: `?ticket=` → portal
- [ ] **T026-12** Poll 30s `/public` + indicador «Atualizado há Xs»
- [ ] **T026-13** `WizardSupportModal`: CTA «Acompanhar» → portal; banner pós-fechar
- [ ] **T026-14** Banner wizard «Chamado OB- aberto · Ver andamento»
- [ ] **T026-15** Integração ASM: `assist.active` no portal
- [ ] **T026-16** «Continuar configuração» → wizard passo derivado checklist

### Desk VM122 (mínimo)

- [ ] **T026-20** Webhook ou endpoint sync status → VM112 OB- (opcional MVP: manual)
- [ ] **T026-21** Documentar flag futura `client_visible` em notas ticket

### Qualidade

- [ ] **T026-30** Teste E2E J1–J4 (UX doc)
- [ ] **T026-31** KB + link Spec 026
- [ ] **T026-32** pt-BR review copy

---

## Fase B — Comunicação (P1)

- [ ] **T026-40** `POST .../note` cliente
- [ ] **T026-41** Desk → OB- timeline sync automático
- [ ] **T026-42** E-mail «chamado actualizado» (Spec 007)
- [ ] **T026-43** Pretty URL `/chamado/OB-XXXX`

---

## Fase C — Agente (P2)

- [ ] **T026-50** `agent_messages` UI
- [ ] **T026-51** Respostas automáticas layman (KIMI)

---

## Dependências

- Spec 010 ASM handoff — portal não conflita
- Spec 025 — «Continuar configuração» alinhado idempotência conta
- `npm run build:wizard` + deploy VM112

---

## Critério done Fase A

Cliente Roger consegue: abrir chamado → Acompanhar → ver portal com OB-, checklist e timeline → Continuar wizard — **sem** ver wizard DNS cru como «acompanhamento».


[REDACTED]

StrReplace

| 2026-06-19 | Spec 027 | Wizard Live Analytics — IP + rota em tempo real (estilo GA) |
| 2026-06-19 | Spec 026 | Portal cliente OB- — acompanhamento chamado wizard (`?ticket=`) |

| 2026-06-19 | Spec 027 | Wizard Live Analytics — IP + rota em tempo real (estilo GA) |

| **025** | `wizard-onboarding-continuity` | **P0** | 🔄 | **Fase 1 ✅** · Fase 2 resume + RAM 16GB |
| **026** | `wizard-client-ticket-portal` | **P0** | 📋 | Portal OB- · UX acompanhamento · Fase A MVP |
| **027** | `wizard-live-analytics` | **P1** | 🔄 | **Fase A/B ✅** · Vite pipeline ✅ · pendente ops_webhook onboarding |

| **025** | `wizard-onboarding-continuity` | **P0** | 🔄 | **Fase 1 ✅** · Fase 2 resume + RAM 16GB |
| **027** | `wizard-live-analytics` | **P1** | 🔄 | **Fase A/B ✅** · Vite pipeline ✅ · pendente ops_webhook onboarding |

| KB-001 | [Wizard — Cloudflare 1002 / domínio inválido](KB-001-WIZARD-CLOUDFLARE-DOMINIO-INVALIDO.md) | VM112 onboarding · DNS |
| — | [Spec 026 — Portal cliente OB-](../specs/026-wizard-client-ticket-portal/spec.md) | Acompanhar chamado · UX |
| — | [ASM handoff vs ticket fechado](../specs/010-desk-assist-takeover/ASM-HANDOFF-FLOW.md) | Spec 010 · assistência |

| KB-001 | [Wizard — Cloudflare 1002 / domínio inválido](KB-001-WIZARD-CLOUDFLARE-DOMINIO-INVALIDO.md) | VM112 onboarding · DNS |
| — | [ASM handoff vs ticket fechado](../specs/010-desk-assist-takeover/ASM-HANDOFF-FLOW.md) | Spec 010 · assistência |

**Relacionado:** [`ASM-HANDOFF-FLOW.md`](ASM-HANDOFF-FLOW.md), [`../026-wizard-client-ticket-portal/spec.md`](../026-wizard-client-ticket-portal/spec.md) (portal OB- cliente)


Roger, confirmado: **026 estava livre** (existem 025 e 027, não havia 026). Criei a **Spec 026** completa.

---

## Onde está

| Documento | Conteúdo |
|-----------|----------|
| [`specs/026-wizard-client-ticket-portal/spec.md`](specs/026-wizard-client-ticket-portal/spec.md) | Arquitectura, API, estados, fases, ligação Desk |
| [`specs/026-wizard-client-ticket-portal/UX-CLIENTE-ACOMPANHAMENTO.md`](specs/026-wizard-client-ticket-portal/UX-CLIENTE-ACOMPANHAMENTO.md) | **Proposta UX completa** — wireframes, copy, jornadas |
| [`specs/026-wizard-client-ticket-portal/tasks.md`](specs/026-wizard-client-ticket-portal/tasks.md) | Checklist implementação |

Também actualizei o **BACKLOG** e a **base de conhecimento**.

---

## Proposta UX ideal (resumo)

A ideia central: **3 camadas num portal dedicado** (`?ticket=OB-…`), não o wizard DNS.

### 1. Status imediato (5 segundos)
- Número **OB-XXXX**, domínio, badge: *Recebido · Em análise · Aguardando você · Em tratamento Ligbox · Resolvido*
- Barra de progresso humana: *«70% — falta publicar webmail»*
- Uma frase layman (já tens no backend)

### 2. Checklist «o que falta»
Traduz infra técnica para linguagem cliente:
- ✓ Conta criada
- ⏳ DNS / nameservers
- ⏳ Webmail online
- ○ Certificado (cadeado)

Sempre claro **quem actua**: Ligbox vs cliente vs «só aguardar propagação».

### 3. Linha do tempo + acções
- Feed: *Chamado registrado → Equipe a verificar DNS → …*
- Botões: **Continuar configuração**, **Copiar link**, **Abrir webmail** (quando OK)
- Banner no wizard enquanto OB- aberto: *«Chamado OB-… em andamento · Ver andamento»*

### Satisfação — o cliente responde «sim» a:
| Pergunta | Onde |
|----------|------|
| Registaram o meu pedido? | OB- + timeline |
| Sabem o meu problema? | Layman + nota |
| O que falta? | Checklist |
| Quem faz o quê agora? | Bloco «owner» |
| Posso voltar depois? | Link bookmarkável |
| Duplicaram chamado? | Idempotência 1 OB- por sessão |

### Integração ASM (Spec 010)
Portal mostra *«Suporte Ligbox activo»* — não erro 423 nem wizard confuso.

---

## Fases de entrega

| Fase | O quê |
|------|--------|
| **A (P0)** | Portal `?ticket=`, API pública, checklist, timeline mínima, botão honesto |
| **B (P1)** | Nota do cliente, sync Desk→OB-, e-mail «chamado actualizado» |
| **C (P2)** | Agente KIMI / respostas automáticas |

---

## Próximo passo

Quando quiseres implementar, a **Fase A** (T026-01 a T026-16) resolve o problema do botão «Acompanhar chamado» que hoje abre o wizard errado.

Queres que eu comece a **Fase A** no VM112, ou preferes rever a UX no doc primeiro?

[REDACTED]


<user_query>
pode trabalhar na SPEC 026 completa incluid a integracao com SPEC 010 e na sequenaci ,( nao me precisa me perguntar ) pode rever /contruir /testrat/finaliza a UX da Spec 026
</user_query>


Implementando a Spec 026 completa: backend, portal cliente, integração ASM (Spec 010) e UX finalizada.

[REDACTED]

TodoWrite

VM112 backend: support_tickets + API public + idempotência

VM112 frontend: ClientTicketPortal + routing + modal + banner

Integração Spec 010 ASM no portal OB-

Build, test E2E, docs/spec status update

Shell

sshpass -p '@betinplace' ssh root@10.10.10.112 "wc -l /opt/ligbox-wizard/backend/app/services/support_tickets.py /opt/ligbox-wizard/backend/app/routers/onboarding.py /opt/ligbox-wizard/frontend/src/App.jsx; ls /opt/ligbox-wizard/frontend/src/*.jsx | head -20"

Survey VM112 files for Spec 026 implementation


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "cat /opt/ligbox-wizard/backend/app/services/support_tickets.py; echo '---'; sed -n '54,175p' /opt/ligbox-wizard/backend/app/routers/onboarding.py"

Read current support tickets backend

sshpass -p '@betinplace' ssh root@10.10.10.112 "grep -n 'def get_status' /opt/ligbox-wizard/backend/app/services/infrastructure.py; sed -n '1,120p' /opt/ligbox-wizard/backend/app/services/infrastructure.py"

Read infrastructure get_status structure

sshpass -p '@betinplace' ssh root@10.10.10.112 "head -80 /opt/ligbox-wizard/frontend/src/main-wizard.jsx; grep -n 'export default\|ticket\|session' /opt/ligbox-wizard/frontend/src/App.jsx | head -30"

Read wizard entry and App routing hooks


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "sed -n '121,220p' /opt/ligbox-wizard/backend/app/services/infrastructure.py"

Read get_status return structure

sshpass -p '@betinplace' ssh root@10.10.10.112 "grep -n 'sessions.*state\|/state' /opt/ligbox-wizard/backend/app/routers/*.py | head -15"

Find assist state endpoint path


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "grep -n 'assist' /opt/ligbox-wizard/backend/app/main.py | head -15"

Assist router mount path


[REDACTED]

sshpass -p '@betinplace' ssh root@10.10.10.112 "head -40 /opt/ligbox-wizard/backend/app/routers/assist.py"

Read assist router prefix


Implementando backend e frontend completos; criando ficheiros de deploy e aplicando na VM112.

[REDACTED]

Write

"""Chamados OB- onboarding — Spec 026 portal cliente + Spec 010 ASM."""

from __future__ import annotations

import json
import secrets
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from app.config import settings
from app.services import activity_log

_TICKETS_DIR = Path("/var/lib/ibytera-mail-portal/tickets")

ACTIVE_STATUSES = frozenset({"open", "in_review", "waiting_client", "in_progress"})
TERMINAL_STATUSES = frozenset({"resolved", "closed"})

STATUS_LABELS = {
    "open": "Recebido",
    "in_review": "Em análise",
    "waiting_client": "Aguardando você",
    "in_progress": "Em tratamento Ligbox",
    "resolved": "Resolvido",
    "closed": "Encerrado",
}

CHECKLIST_CLIENT = {
    "carbonio_domain": {
        "label": "Conta de e-mail criada no servidor",
        "hint_ok": "Domínio activo no servidor de e-mail.",
        "hint_pending": "Será criada ao concluir o passo «Criar conta» no wizard.",
    },
    "dns_mail": {
        "label": "DNS para enviar e receber e-mails",
        "hint_ok": "Registos MX e A do mail estão correctos.",
        "hint_pending": "Confirme os apontamentos DNS ou nameservers no registrador.",
    },
    "haproxy_sni": {
        "label": "Publicação do endereço mail na rede",
        "hint_ok": "Endereço mail publicado.",
        "hint_pending": "A equipe Ligbox está a activar a ligação na internet.",
    },
    "traefik_router": {
        "label": "Webmail preparado (HTTPS)",
        "hint_ok": "Servidor webmail configurado.",
        "hint_pending": "Configuração HTTPS em progresso.",
    },
    "cert_san": {
        "label": "Certificado de segurança (cadeado)",
        "hint_ok": "Certificado activo para o webmail.",
        "hint_pending": "Certificado a ser emitido — normal nesta fase.",
    },
    "webmail_https": {
        "label": "Webmail acessível na internet",
        "hint_ok": "Pode aceder ao webmail no navegador.",
        "hint_pending": "O endereço mail ainda não responde — aguarde DNS ou suporte.",
    },
}

OWNER_COPY = {
    "ligbox": {
        "title": "Equipe Ligbox",
        "body": "A equipe Ligbox está a tratar deste pedido. Você não precisa fazer nada agora.",
    },
    "client": {
        "title": "Sua acção",
        "body": "Precisamos que confirme os nameservers ou apontamentos DNS no registrador do domínio.",
    },
    "waiting_dns": {
        "title": "Aguardando internet",
        "body": "Nameservers alterados — aguardamos a propagação DNS (pode levar até 24–48 horas).",
    },
    "none_done": {
        "title": "Concluído",
        "body": "Tudo pronto — pode aceder ao webmail.",
    },
    "assist_active": {
        "title": "Suporte Ligbox activo",
        "body": "Um técnico está a tratar desta configuração. O assistente wizard está pausado — acompanhe aqui.",
    },
}

def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()

def _ticket_path(ticket_id: str) -> Path:
    safe = ticket_id.lower().strip().replace("/", "_")
    return _TICKETS_DIR / f"{safe}.json"

def build_layman_explanation(
    domain: str,
    email: str | None,
    infra_status: dict | None,
) -> dict[str, Any]:
    mail_host = (infra_status or {}).get("mail_host") or f"mail.{domain}"
    pending = [s for s in (infra_status or {}).get("steps", []) if not s.get("ok")]
    pending_labels = [s.get("label", s.get("id", "?")) for s in pending]

    paragraphs = [
        f"Sua conta de e-mail{' ' + email if email else ''} já foi criada no nosso servidor — "
        "você pode gerenciar usuários e continuar a configuração no portal.",
        f"O endereço público do webmail ({mail_host}) ainda não abre no navegador porque faltam "
        "passos de ligação à internet. Isso é frequente e não significa que algo falhou na criação da conta.",
        "Na maioria dos casos é necessário confirmar os nameservers no registrador do domínio "
        "ou aguardar a propagação do DNS — tarefas que a equipe Ligbox pode concluir por você.",
    ]

    if pending_labels:
        paragraphs.append(
            "Resumo do que ainda falta: "
            + "; ".join(pending_labels[:4])
            + ("." if len(pending_labels) <= 4 else "; …")
        )

    return {
        "title": "Por que o webmail ainda não abre?",
        "paragraphs": paragraphs,
        "pending_count": len(pending),
        "mail_host": mail_host,
    }

def build_technical_context(domain: str, session_id: str, infra_status: dict | None) -> dict[str, Any]:
    entries, sessions_merged = activity_log.get_entries_for_domain(domain)
    return {
        "domain": domain,
        "session_id": session_id,
        "sessions_merged": sessions_merged,
        "infra_status": infra_status,
        "activity_entries": entries[-120:],
        "generated_at": _now_iso(),
    }

def support_session_url(session_id: str, ticket_id: str | None = None) -> str:
    base = settings.onboard_public_url.rstrip("/")
    path = settings.onboard_public_path or "/onboard"
    url = f"{base}{path}?session={session_id}"
    if ticket_id:
        url += f"&ticket={ticket_id}"
    return url

def _default_timeline(ticket_id: str, client_note: str | None) -> list[dict[str, Any]]:
    events = [
        {
            "at": _now_iso(),
            "kind": "milestone",
            "title": "Chamado registrado",
            "body": f"Referência {ticket_id}. Nossa equipe recebeu o histórico desta sessão.",
        }
    ]
    if client_note:
        events.append(
            {
                "at": _now_iso(),
                "kind": "client_note",
                "title": "Sua mensagem",
                "body": client_note.strip(),
            }
        )
    return events

def _iter_tickets() -> list[dict[str, Any]]:
    if not _TICKETS_DIR.is_dir():
        return []
    out: list[dict[str, Any]] = []
    for p in _TICKETS_DIR.glob("ob-*.json"):
        try:
            out.append(json.loads(p.read_text(encoding="utf-8")))
        except (json.JSONDecodeError, OSError):
            continue
    return out

def find_open_ticket_for_session(session_id: str) -> dict[str, Any] | None:
    sid = (session_id or "").strip()
    if not sid:
        return None
    matches = [
        t
        for t in _iter_tickets()
        if (t.get("session_id") or "").strip() == sid and t.get("status") in ACTIVE_STATUSES
    ]
    if not matches:
        return None
    matches.sort(key=lambda x: x.get("created_at") or "", reverse=True)
    return matches[0]

def create_ticket(
    *,
    domain: str,
    session_id: str,
    account_email: str | None = None,
    infra_status: dict | None = None,
    client_note: str | None = None,
    category: str = "infra_webmail_pending",
) -> dict[str, Any]:
    domain = domain.lower().strip()
    ticket_id = f"OB-{secrets.token_hex(4).upper()}"
    layman = build_layman_explanation(domain, account_email, infra_status)
    context = build_technical_context(domain, session_id, infra_status)
    note = (client_note or "").strip() or None

    ticket = {
        "ticket_id": ticket_id,
        "domain": domain,
        "session_id": session_id,
        "account_email": account_email,
        "category": category,
        "status": "open",
        "owner": "ligbox",
        "phase": "portal",
        "client_note": note,
        "layman": layman,
        "infra_status": infra_status,
        "support_url": support_session_url(session_id, ticket_id),
        "tracking_url": support_session_url(session_id, ticket_id),
        "desk_ticket_id": None,
        "created_at": _now_iso(),
        "updated_at": _now_iso(),
        "timeline": _default_timeline(ticket_id, note),
        "agent_messages": [],
        "context_snapshot_id": str(uuid.uuid4()),
    }

    _TICKETS_DIR.mkdir(parents=True, exist_ok=True)
    _ticket_path(ticket_id).write_text(json.dumps(ticket, indent=2, ensure_ascii=False), encoding="utf-8")
    ctx_path = _TICKETS_DIR / f"{ticket_id}.context.json"
    ctx_path.write_text(json.dumps(context, indent=2, ensure_ascii=False), encoding="utf-8")

    activity_log.info(f"Chamado {ticket_id} aberto — domínio {domain} ({category})", source="portal")
    return ticket

def find_or_create_ticket(**kwargs) -> tuple[dict[str, Any], bool]:
    session_id = kwargs.get("session_id") or ""
    existing = find_open_ticket_for_session(session_id)
    if existing:
        return existing, False
    return create_ticket(**kwargs), True

def save_ticket(ticket: dict[str, Any]) -> None:
    ticket_id = ticket.get("ticket_id")
    if not ticket_id:
        return
    ticket["updated_at"] = _now_iso()
    _TICKETS_DIR.mkdir(parents=True, exist_ok=True)
    _ticket_path(ticket_id).write_text(json.dumps(ticket, indent=2, ensure_ascii=False), encoding="utf-8")

def get_ticket(ticket_id: str) -> dict | None:
    p = _ticket_path(ticket_id)
    if not p.is_file():
        return None
    try:
        return json.loads(p.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return None

def append_timeline(
    ticket: dict[str, Any],
    *,
    kind: str,
    title: str,
    body: str,
) -> None:
    timeline = list(ticket.get("timeline") or [])
    timeline.insert(
        0,
        {"at": _now_iso(), "kind": kind, "title": title, "body": body},
    )
    ticket["timeline"] = timeline[:40]
    save_ticket(ticket)

def build_client_checklist(infra_status: dict | None) -> list[dict[str, Any]]:
    steps = (infra_status or {}).get("steps") or []
    out: list[dict[str, Any]] = []
    for step in steps:
        sid = step.get("id") or ""
        meta = CHECKLIST_CLIENT.get(sid, {})
        ok = bool(step.get("ok"))
        out.append(
            {
                "id": sid,
                "label": meta.get("label") or step.get("label") or sid,
                "ok": ok,
                "hint": meta.get("hint_ok") if ok else (meta.get("hint_pending") or step.get("message")),
            }
        )
    return out

def _progress_percent(checklist: list[dict[str, Any]]) -> int:
    if not checklist:
        return 0
    ok = sum(1 for c in checklist if c.get("ok"))
    return int(round(100 * ok / len(checklist)))

def _derive_owner(infra_status: dict | None, assist_state: dict | None) -> str:
    if assist_state and assist_state.get("paused") and assist_state.get("assist_mode") == "asm":
        return "assist_active"
    steps = (infra_status or {}).get("steps") or []
    if infra_status and infra_status.get("ready"):
        return "none_done"
    dns = next((s for s in steps if s.get("id") == "dns_mail"), None)
    if dns and not dns.get("ok"):
        return "client"
    pending = [s for s in steps if not s.get("ok")]
    if pending:
        return "ligbox"
    return "waiting_dns"

def _derive_status(ticket: dict[str, Any], infra_status: dict | None, assist_state: dict | None) -> str:
    current = ticket.get("status") or "open"
    if current in TERMINAL_STATUSES:
        return current
    if assist_state and assist_state.get("paused") and assist_state.get("assist_mode") == "asm":
        return "in_progress"
    if infra_status and infra_status.get("ready"):
        return "resolved"
    owner = _derive_owner(infra_status, assist_state)
    if owner == "client":
        return "waiting_client"
    if owner == "waiting_dns":
        return "in_progress"
    if current == "open":
        return "in_review"
    return current

def _wizard_continue_step(infra_status: dict | None) -> int:
    if not infra_status:
        return 0
    if infra_status.get("ready"):
        return 4
    steps = infra_status.get("steps") or []
    carbonio = next((s for s in steps if s.get("id") == "carbonio_domain"), None)
    if carbonio and carbonio.get("ok"):
        return 1
    return 0

def build_public_view(
    ticket: dict[str, Any],
    *,
    infra_status: dict | None = None,
    assist_state: dict | None = None,
) -> dict[str, Any]:
    infra = infra_status or ticket.get("infra_status") or {}
    checklist = build_client_checklist(infra)
    owner = _derive_owner(infra, assist_state)
    status = _derive_status(ticket, infra, assist_state)
    progress = _progress_percent(checklist)
    mail_host = infra.get("mail_host") or f"mail.{ticket.get('domain', '')}"
    owner_copy = OWNER_COPY.get(owner, OWNER_COPY["ligbox"])
    step = _wizard_continue_step(infra)

    if status == "resolved" and ticket.get("status") not in TERMINAL_STATUSES:
        ticket["status"] = "resolved"
        append_timeline(
            ticket,
            kind="success",
            title="Webmail disponível",
            body="Todos os passos de infraestrutura foram concluídos.",
        )

    if assist_state and assist_state.get("paused") and assist_state.get("assist_mode") == "asm":
        if not ticket.get("_assist_logged"):
            append_timeline(
                ticket,
                kind="update",
                title="Suporte Ligbox activo",
                body="Um técnico assumiu esta sessão para concluir a configuração.",
            )
            ticket["_assist_logged"] = True
            save_ticket(ticket)
    elif ticket.get("_assist_logged") and not (assist_state and assist_state.get("paused")):
        append_timeline(
            ticket,
            kind="update",
            title="Controle devolvido ao cliente",
            body="Você pode continuar a configuração no wizard quando quiser.",
        )
        ticket["_assist_logged"] = False
        save_ticket(ticket)

    return {
        "ticket_id": ticket.get("ticket_id"),
        "status": status,
        "status_label": STATUS_LABELS.get(status, status),
        "owner": owner,
        "owner_title": owner_copy["title"],
        "owner_message": owner_copy["body"],
        "domain": ticket.get("domain"),
        "account_email": ticket.get("account_email"),
        "session_id": ticket.get("session_id"),
        "client_note": ticket.get("client_note"),
        "created_at": ticket.get("created_at"),
        "updated_at": ticket.get("updated_at"),
        "layman": ticket.get("layman") or build_layman_explanation(
            ticket.get("domain", ""),
            ticket.get("account_email"),
            infra,
        ),
        "checklist": checklist,
        "progress_percent": progress,
        "progress_label": (
            "Tudo concluído — webmail pronto"
            if progress >= 100
            else f"{progress}% — activação em progresso"
        ),
        "timeline": ticket.get("timeline") or [],
        "mail_host": mail_host,
        "webmail_url": f"https://{mail_host}/" if mail_host.startswith("mail.") else f"https://mail.{ticket.get('domain')}/",
        "tracking_url": ticket.get("tracking_url") or ticket.get("support_url"),
        "actions": {
            "can_continue_wizard": status not in TERMINAL_STATUSES or not infra.get("ready"),
            "can_add_note": status not in TERMINAL_STATUSES,
            "can_open_webmail": bool(infra.get("ready")),
            "wizard_step": step,
            "wizard_url": support_session_url(ticket.get("session_id", "")).split("?")[0]
            + f"?session={ticket.get('session_id', '')}",
        },
        "assist": {
            "active": bool(
                assist_state and assist_state.get("paused") and assist_state.get("assist_mode") == "asm"
            ),
            "technician": (assist_state or {}).get("assisted_by"),
            "message": (
                "Suporte Ligbox activo — wizard pausado."
                if assist_state and assist_state.get("paused")
                else None
            ),
        },
    }

def add_client_note(ticket_id: str, session_id: str, message: str) -> dict[str, Any] | None:
    ticket = get_ticket(ticket_id)
    if not ticket or (ticket.get("session_id") or "").strip() != (session_id or "").strip():
        return None
    msg = (message or "").strip()
    if not msg:
        return None
    append_timeline(ticket, kind="client_note", title="Você", body=msg)
    ticket["client_note"] = msg
    save_ticket(ticket)
    return ticket


[REDACTED]

import { useCallback, useEffect, useState } from 'react'
import { ArrowRight, CheckCircle2, Circle, Clock, Copy, HelpCircle, Loader2 } from 'lucide-react'
import './client-ticket-portal.css'

function fmtDate(iso) {
  if (!iso) return '—'
  try {
    return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' })
  } catch {
    return iso
  }
}

function CheckIcon({ ok }) {
  if (ok) return <CheckCircle2 size={20} className="ctp-check ctp-check--ok" aria-hidden />
  return <Clock size={20} className="ctp-check ctp-check--pending" aria-hidden />
}

export default function ClientTicketPortal({ ticketId, sessionId: sessionIdProp }) {
  const sessionId = sessionIdProp || ''
  const [data, setData] = useState(null)
  const [error, setError] = useState(null)
  const [loading, setLoading] = useState(true)
  const [note, setNote] = useState('')
  const [noteBusy, setNoteBusy] = useState(false)
  const [copied, setCopied] = useState(false)
  const [lastPoll, setLastPoll] = useState(null)

  const fetchPortal = useCallback(async () => {
    if (!ticketId || !sessionId) {
      setError('Link incompleto — faltam referência do chamado ou sessão.')
      setLoading(false)
      return
    }
    try {
      const q = new URLSearchParams({ session_id: sessionId })
      const res = await fetch(
        `/api/onboarding/support/ticket/${encodeURIComponent(ticketId)}/public?${q}`,
        { headers: { 'X-Onboarding-Session': sessionId } },
      )
      const body = await res.json().catch(() => ({}))
      if (!res.ok) {
        throw new Error(body.detail?.message || body.detail || 'Chamado não encontrado.')
      }
      setData(body)
      setError(null)
      setLastPoll(new Date())
    } catch (e) {
      setError(e.message || 'Não foi possível carregar o chamado.')
    } finally {
      setLoading(false)
    }
  }, [ticketId, sessionId])

  useEffect(() => {
    fetchPortal()
    const t = setInterval(fetchPortal, 30000)
    return () => clearInterval(t)
  }, [fetchPortal])

  async function submitNote(e) {
    e.preventDefault()
    if (!note.trim() || noteBusy) return
    setNoteBusy(true)
    try {
      const res = await fetch(
        `/api/onboarding/support/ticket/${encodeURIComponent(ticketId)}/note`,
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-Onboarding-Session': sessionId,
          },
          body: JSON.stringify({ session_id: sessionId, message: note.trim() }),
        },
      )
      if (!res.ok) throw new Error('Falha ao enviar')
      setNote('')
      await fetchPortal()
    } catch {
      setError('Não foi possível enviar a mensagem.')
    } finally {
      setNoteBusy(false)
    }
  }

  function copyLink() {
    navigator.clipboard?.writeText(window.location.href).then(() => {
      setCopied(true)
      setTimeout(() => setCopied(false), 2500)
    })
  }

  function continueWizard() {
    const base = data?.actions?.wizard_url || '/onboard'
    const url = `${base}${base.includes('?') ? '&' : '?'}session=${encodeURIComponent(sessionId)}`
    window.location.assign(url)
  }

  if (loading && !data) {
    return (

          <Loader2 size={28} className="ctp-spin" aria-hidden />

Carregando seu chamado…

    )
  }

  if (error && !data) {
    return (

          <HelpCircle size={32} aria-hidden />

Não foi possível abrir o chamado

{error}

    )
  }

  const d = data
  const statusCls = `ctp-status ctp-status--${(d.status || 'open').replace('_', '-')}`

  return (

Ligbox

          Chamado {d.ticket_id}
          {d.domain && {d.domain}}

      {d.assist?.active && (

          Suporte Ligbox activo

            {d.assist.message || 'Um técnico está a tratar desta configuração. O wizard está pausado.'}
            {d.assist.technician ? ` · Técnico: ${d.assist.technician}` : ''}

      )}

        {d.status_label}

          <div className="ctp-progress__bar" style={{ width: `${d.progress_percent || 0}%` }} />

{d.progress_label}

        {d.layman?.paragraphs?.[0] &&
{d.layman.paragraphs[0]}
}

{d.owner_title}

{d.owner_message}

Progresso da activação

          {(d.checklist || []).map((item) => (
            <li key={item.id} className={`ctp-checklist__item${item.ok ? ' is-ok' : ''}`}>
              <CheckIcon ok={item.ok} />

                {item.label}
                {item.hint &&
{item.hint}
}

          ))}

Histórico

          {(d.timeline || []).map((ev, i) => (
            <li key={`${ev.at}-${i}`} className={`ctp-timeline__item ctp-timeline__item--${ev.kind || 'update'}`}>
              <time>{fmtDate(ev.at)}</time>
              {ev.title}
              {ev.body &&
{ev.body}
}

          ))}

        {lastPoll && (

Actualizado {fmtDate(lastPoll.toISOString())} · actualiza a cada 30s

        )}

      {d.actions?.can_add_note && (

Adicionar informação

          <form onSubmit={submitNote} className="ctp-note-form">
            <textarea
              rows={3}
              value={note}
              onChange={(e) => setNote(e.target.value)}
              placeholder="Ex.: já alterei os nameservers ontem…"
            />
            <button type="submit" className="ctp-btn ctp-btn--secondary" disabled={noteBusy || !note.trim()}>
              {noteBusy ? 'Enviando…' : 'Enviar mensagem'}
            </button>
          </form>

      )}

        {d.actions?.can_open_webmail && (

            Abrir webmail

        )}
        {d.actions?.can_continue_wizard && !d.assist?.active && (
          <button type="button" className="ctp-btn ctp-btn--primary" onClick={continueWizard}>
            Continuar configuração
            <ArrowRight size={18} aria-hidden />
          </button>
        )}
        <button type="button" className="ctp-btn ctp-btn--ghost" onClick={copyLink}>
          <Copy size={16} aria-hidden />
          {copied ? 'Link copiado ✓' : 'Copiar link deste chamado'}
        </button>

  )
}

/* Spec 026 — Portal cliente OB- */

.ctp-page {
  min-height: 100vh;
  background: linear-gradient(165deg, #0f172a 0%, #1e293b 45%, #0f172a 100%);
  color: #e2e8f0;
  padding: 1.25rem 1rem 2.5rem;
  max-width: 520px;
  margin: 0 auto;
  font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}

.ctp-loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1rem;
  padding: 4rem 1rem;
  color: #94a3b8;
}

.ctp-spin {
  animation: ctp-spin 1s linear infinite;
}

@keyframes ctp-spin {
  to { transform: rotate(360deg); }
}

.ctp-header {
  margin-bottom: 1.25rem;
}

.ctp-header__brand {
  font-weight: 700;
  font-size: 1.1rem;
  letter-spacing: 0.04em;
  color: #f8fafc;
}

.ctp-header__meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1rem;
  margin-top: 0.35rem;
  font-size: 0.85rem;
  color: #94a3b8;
}

.ctp-header__ticket {
  font-weight: 600;
  color: #cbd5e1;
}

.ctp-assist-banner {
  background: rgba(180, 83, 9, 0.2);
  border: 1px solid rgba(251, 191, 36, 0.45);
  border-radius: 10px;
  padding: 0.85rem 1rem;
  margin-bottom: 1rem;
}

.ctp-assist-banner strong {
  color: #fcd34d;
  display: block;
  margin-bottom: 0.25rem;
}

.ctp-assist-banner p {
  margin: 0;
  font-size: 0.88rem;
  color: #fde68a;
}

.ctp-card {
  background: rgba(30, 41, 59, 0.85);
  border: 1px solid rgba(148, 163, 184, 0.15);
  border-radius: 12px;
  padding: 1.1rem 1.15rem;
  margin-bottom: 1rem;
}

.ctp-card--error {
  text-align: center;
  padding: 2rem;
  color: #fca5a5;
}

.ctp-hero .ctp-lead {
  margin: 0.75rem 0 0;
  font-size: 0.92rem;
  line-height: 1.5;
  color: #cbd5e1;
}

.ctp-status {
  display: inline-block;
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 0.25rem 0.55rem;
  border-radius: 6px;
}

.ctp-status--open { background: #1e3a5f; color: #93c5fd; }
.ctp-status--in-review { background: #422006; color: #fcd34d; }
.ctp-status--waiting-client { background: #450a0a; color: #fca5a5; }
.ctp-status--in-progress { background: #431407; color: #fdba74; }
.ctp-status--resolved { background: #14532d; color: #86efac; }
.ctp-status--closed { background: #334155; color: #94a3b8; }

.ctp-progress {
  height: 6px;
  background: #334155;
  border-radius: 999px;
  margin-top: 0.85rem;
  overflow: hidden;
}

.ctp-progress__bar {
  height: 100%;
  background: linear-gradient(90deg, #22c55e, #4ade80);
  border-radius: 999px;
  transition: width 0.4s ease;
}

.ctp-progress__label {
  margin: 0.45rem 0 0;
  font-size: 0.82rem;
  color: #94a3b8;
}

.ctp-section-title {
  margin: 0 0 0.65rem;
  font-size: 0.95rem;
  font-weight: 600;
  color: #f1f5f9;
}

.ctp-owner-msg {
  margin: 0;
  font-size: 0.9rem;
  line-height: 1.55;
  color: #cbd5e1;
}

.ctp-checklist {
  list-style: none;
  margin: 0;
  padding: 0;
}

.ctp-checklist__item {
  display: flex;
  gap: 0.75rem;
  padding: 0.65rem 0;
  border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}

.ctp-checklist__item:last-child {
  border-bottom: none;
}

.ctp-check--ok { color: #4ade80; flex-shrink: 0; }
.ctp-check--pending { color: #fbbf24; flex-shrink: 0; }

.ctp-checklist__hint {
  margin: 0.2rem 0 0;
  font-size: 0.8rem;
  color: #94a3b8;
}

.ctp-timeline {
  list-style: none;
  margin: 0;
  padding: 0;
}

.ctp-timeline__item {
  padding: 0.65rem 0;
  border-bottom: 1px solid rgba(148, 163, 184, 0.08);
  font-size: 0.88rem;
}

.ctp-timeline__item time {
  display: block;
  font-size: 0.72rem;
  color: #64748b;
  margin-bottom: 0.15rem;
}

.ctp-timeline__item strong {
  color: #e2e8f0;
}

.ctp-timeline__item p {
  margin: 0.25rem 0 0;
  color: #94a3b8;
  line-height: 1.45;
}

.ctp-meta {
  margin: 0.75rem 0 0;
  font-size: 0.72rem;
  color: #64748b;
}

.ctp-note-form textarea {
  width: 100%;
  box-sizing: border-box;
  border-radius: 8px;
  border: 1px solid #475569;
  background: #0f172a;
  color: #e2e8f0;
  padding: 0.65rem;
  font: inherit;
  resize: vertical;
}

.ctp-note-form button {
  margin-top: 0.5rem;
}

.ctp-actions {
  display: flex;
  flex-direction: column;
  gap: 0.65rem;
  margin-top: 0.5rem;
}

.ctp-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  padding: 0.75rem 1rem;
  border-radius: 8px;
  font-weight: 600;
  font-size: 0.92rem;
  border: none;
  cursor: pointer;
  text-decoration: none;
  text-align: center;
}

.ctp-btn--primary {
  background: #b91c1c;
  color: #fff;
}

.ctp-btn--primary:hover {
  background: #991b1b;
}

.ctp-btn--secondary {
  background: #334155;
  color: #f1f5f9;
}

.ctp-btn--ghost {
  background: transparent;
  color: #94a3b8;
  border: 1px solid #475569;
}

#!/usr/bin/env python3
"""Apply Spec 026 patches to VM112 onboarding router."""

from pathlib import Path

ROUTER = Path("/opt/ligbox-wizard/backend/app/routers/onboarding.py")
text = ROUTER.read_text()

if "support/ticket/{ticket_id}/public" not in text:
    insert_after = '''@router.get("/support/ticket/{ticket_id}")
def get_support_ticket(ticket_id: str):
    ticket = support_tickets.get_ticket(ticket_id)
    if not ticket:
        raise HTTPException(404, "Chamado não encontrado.")
    return ticket

'''
    new_routes = '''@router.get("/support/ticket/{ticket_id}")
def get_support_ticket(ticket_id: str):
    ticket = support_tickets.get_ticket(ticket_id)
    if not ticket:
        raise HTTPException(404, "Chamado não encontrado.")
    return ticket

class SupportTicketNoteRequest(BaseModel):
    session_id: str = Field(..., min_length=8)
    message: str = Field(..., min_length=1, max_length=2000)

@router.get("/support/ticket/by-session")
def get_open_ticket_for_session(session_id: str = Depends(bind_onboarding_session)):
    """Chamado OB- aberto para banner no wizard."""
    ticket = support_tickets.find_open_ticket_for_session(session_id or "")
    if not ticket:
        return {"ticket": None}
    return {
        "ticket": {
            "ticket_id": ticket.get("ticket_id"),
            "tracking_url": ticket.get("tracking_url") or ticket.get("support_url"),
            "status": ticket.get("status"),
        }
    }

@router.get("/support/ticket/{ticket_id}/public")
def get_support_ticket_public(
    ticket_id: str,
    session_id: str = Query(..., min_length=8),
):
    """Portal cliente Spec 026 — requer par ticket + session."""
    from app.services import assist_session, infrastructure
    from app.services.domain_registry import get_domain_record

    ticket = support_tickets.get_ticket(ticket_id.strip().upper())
    if not ticket:
        raise HTTPException(404, detail={"message": "Chamado não encontrado."})
    if (ticket.get("session_id") or "").strip() != session_id.strip():
        raise HTTPException(403, detail={"message": "Sessão não corresponde a este chamado."})

    domain = ticket.get("domain") or ""
    rec = get_domain_record(domain) if domain else None
    aliases = (rec or {}).get("mail_aliases") or []
    infra_status = infrastructure.get_status(domain, aliases) if domain else {}
    ticket["infra_status"] = infra_status
    assist_state = assist_session.public_state(session_id.strip())
    return support_tickets.build_public_view(ticket, infra_status=infra_status, assist_state=assist_state)

@router.post("/support/ticket/{ticket_id}/note")
def add_support_ticket_note(
    ticket_id: str,
    body: SupportTicketNoteRequest,
    session_id: str = Depends(bind_onboarding_session),
):
    sid = (body.session_id or session_id or "").strip()
    updated = support_tickets.add_client_note(ticket_id.strip().upper(), sid, body.message)
    if not updated:
        raise HTTPException(404, detail={"message": "Chamado não encontrado ou sessão inválida."})
    from app.services import assist_session, infrastructure
    from app.services.domain_registry import get_domain_record

    domain = updated.get("domain") or ""
    rec = get_domain_record(domain) if domain else None
    aliases = (rec or {}).get("mail_aliases") or []
    infra_status = infrastructure.get_status(domain, aliases) if domain else {}
    assist_state = assist_session.public_state(sid)
    return support_tickets.build_public_view(updated, infra_status=infra_status, assist_state=assist_state)

'''
    if insert_after not in text:
        raise SystemExit("anchor not found for public routes")
    text = text.replace(insert_after, new_routes)

# idempotent create
old_create = '''    ticket = support_tickets.create_ticket(
        domain=domain,
        session_id=session_id or "",
        account_email=body.account_email,
        infra_status=infra_status,
        client_note=body.client_note,
    )'''
new_create = '''    ticket, created = support_tickets.find_or_create_ticket(
        domain=domain,
        session_id=session_id or "",
        account_email=body.account_email,
        infra_status=infra_status,
        client_note=body.client_note,
    )'''
if old_create in text:
    text = text.replace(old_create, new_create)

old_log = '''    activity_log.info(
        f"Cliente abriu chamado {ticket['ticket_id']} — suporte notificado",
        source="portal",
    )

    return {
        "ticket_id": ticket["ticket_id"],
        "support_url": ticket["support_url"],
        "layman": layman,
        "message": "Chamado registrado. Nossa equipe (assistente + suporte humano) recebeu o histórico desta sessão.",
    }'''
new_log = '''    if created:
        activity_log.info(
            f"Cliente abriu chamado {ticket['ticket_id']} — suporte notificado",
            source="portal",
        )
    else:
        activity_log.info(
            f"Cliente reutilizou chamado {ticket['ticket_id']} — sem duplicar",
            source="portal",
        )

    msg = (
        "Chamado registrado. Nossa equipe (assistente + suporte humano) recebeu o histórico desta sessão."
        if created
        else f"Já existe um chamado aberto ({ticket['ticket_id']}). Acompanhe o andamento abaixo."
    )

    return {
        "ticket_id": ticket["ticket_id"],
        "support_url": ticket["support_url"],
        "tracking_url": ticket.get("tracking_url") or ticket["support_url"],
        "created": created,
        "layman": layman,
        "message": msg,
    }'''
if old_log in text:
    text = text.replace(old_log, new_log)

# only webhook on create
old_webhook = '''    background_tasks.add_task(
        ops_webhook.emit_event,
        "onboarding.escalated",
        domain=domain,
        session_id=session_id or "",
        data={
            "reason": "client_support_request",
            "wizard_ticket_id": ticket["ticket_id"],
            "client_note": body.client_note,
            "source": "wizard_support_modal",
        },
    )

    if created:'''
if old_webhook not in text and "if created:" not in text.split("background_tasks.add_task")[1][:200] if "background_tasks.add_task" in text else True:
    old_wh = '''    background_tasks.add_task(
        ops_webhook.emit_event,
        "onboarding.escalated",
        domain=domain,
        session_id=session_id or "",
        data={
            "reason": "client_support_request",
            "wizard_ticket_id": ticket["ticket_id"],
            "client_note": body.client_note,
            "source": "wizard_support_modal",
        },
    )

    activity_log.info('''
    new_wh = '''    if created:
        background_tasks.add_task(
            ops_webhook.emit_event,
            "onboarding.escalated",
            domain=domain,
            session_id=session_id or "",
            data={
                "reason": "client_support_request",
                "wizard_ticket_id": ticket["ticket_id"],
                "client_note": body.client_note,
                "source": "wizard_support_modal",
            },
        )

    activity_log.info('''
    if old_wh in text:
        text = text.replace(old_wh, new_wh)

ROUTER.write_text(text)
print("onboarding.py patched OK")