Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,9 @@ e2e/.auth/

# TypeScript incremental build artifacts
*.tsbuildinfo

# Deno lock files — generated por `deno check`/`deno test` localmente.
# Edge functions são deployadas pelo Lovable Cloud sem necessidade de
# pinning reproduzível, e CI sempre faz fresh resolution via deno.json.
deno.lock
**/deno.lock
188 changes: 188 additions & 0 deletions docs/RUNBOOK_CONNECTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# 🔌 Runbook — Módulo `/admin/conexoes`

Guia operacional do módulo de Conexões: diagnóstico, rotação de credenciais,
auto-test cron, e interpretação de logs.

---

## 🩺 Sintoma → Causa → Ação

### "Carregando..." infinito no seletor de empresas (front)

**Sintoma:** UI fica travada em "Carregando..." por 15-20s antes de ficar vazio.

**Causa raiz:** `crm-db-bridge` não consegue resolver `EXTERNAL_CRM_URL` /
`EXTERNAL_CRM_SERVICE_ROLE_KEY`. React Query reentra 3× em cada erro 500.

**Diagnóstico:**

```sql
-- 1. Confirmar que credenciais existem no DB
SELECT secret_name, secret_source, is_active
FROM public.integration_credentials
WHERE secret_name LIKE 'EXTERNAL_CRM%';

-- 2. Ver últimos erros do bridge nos logs estruturados
-- Filtrar por evt='credential_resolved' e source='none'
```

**Ação:**

1. Cadastrar credenciais via `/admin/conexoes` ou diretamente:
```sql
-- Via secrets-manager (preferido)
-- POST /functions/v1/secrets-manager
-- { "action": "set", "name": "EXTERNAL_CRM_URL", "value": "https://..." }
```
2. Aliases legados (`CRM_SUPABASE_URL`) também funcionam como fallback via env.

---

### Banner "Credenciais alteradas" não mostra qual secret mudou

**Causa raiz:** payload realtime usa coluna `secret_name`, não `name`.
Foi corrigido em PR #70 (`CredentialsChangedBanner.tsx`).

**Validar:** alterar uma credencial e verificar que o toast mostra o nome.

---

### `AutoTestJobStatusCard` sempre "untested"

**Causa raiz:** cron `connections-auto-test` órfão (sem schedule).

**Diagnóstico:**

```sql
SELECT jobname, schedule, active
FROM cron.job
WHERE jobname = 'connections-auto-test';
-- Se vazio: migration 20260429163414_* não foi aplicada
```

**Ação:**

```sql
-- Aplicar a migration ou rodar manualmente:
SELECT cron.schedule(
'connections-auto-test',
'*/15 * * * *',
$$ SELECT net.http_post(...); $$
);
```

**Validar intervalo configurado:**

```sql
SELECT public.get_connections_auto_test_interval();
-- Deve retornar 15 (default) ou outro valor configurado via UI.
```

---

## 🔐 Rotação de Credenciais CRM

1. **Provedor:** gerar nova `service_role` key no dashboard Supabase do CRM.
2. **Aplicar:** `/admin/conexoes` → Tab "Supabase" → Card CRM → "Rotacionar".
3. **Verificar:** seletor de empresas no front carrega em <3s.
4. **Auditar:** linha em `secret_rotation_log` com user_id e timestamp.

> Cache TTL é 60s — propagação para edge functions é praticamente instantânea
> via `invalidateCredentialCache()` chamado pelo secrets-manager após rotação.

---

## 🩺 Endpoints de Diagnóstico do `crm-db-bridge`

Todos com **bypass de auth** — operadores precisam diagnosticar mesmo com JWT
quebrado ou breaker aberto.

```
GET /functions/v1/crm-db-bridge?op=ping → liveness
GET /functions/v1/crm-db-bridge?op=diag → boot/runtime metrics
GET /functions/v1/crm-db-bridge?op=breaker_status → circuit breaker
GET /functions/v1/crm-db-bridge?op=creds_health → resolução das credenciais
```
Comment on lines +100 to +105
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language tag to the fenced code block.

This block is missing a fence language and can trigger markdown lint failures (MD040).

Suggested fix
-```
+```text
 GET  /functions/v1/crm-db-bridge?op=ping            → liveness
 GET  /functions/v1/crm-db-bridge?op=diag            → boot/runtime metrics
 GET  /functions/v1/crm-db-bridge?op=breaker_status  → circuit breaker
 GET  /functions/v1/crm-db-bridge?op=creds_health    → resolução das credenciais
</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.22.1)</summary>

[warning] 100-100: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @docs/RUNBOOK_CONNECTIONS.md around lines 100 - 105, The fenced code block
containing the GET endpoint lines (e.g., "GET
/functions/v1/crm-db-bridge?op=ping", "GET ...?op=diag", "GET
...?op=breaker_status", "GET ...?op=creds_health") is missing a language tag and
triggers MD040; add a language tag (e.g., text) to the opening fence so the block becomes text and the linter will accept it.


</details>

<!-- fingerprinting:phantom:medusa:grasshopper:f4d4f2f5-e163-443a-8ee4-509830b7f93d -->

<!-- d98c2f50 -->

<!-- This is an auto-generated comment by CodeRabbit -->


Exemplo de `creds_health` em estado saudável:

```json
{
"ok": true, "ts": 1745000000000, "health": "healthy",
"credentials": [
{ "name": "EXTERNAL_CRM_URL", "present": true,
"source": "db", "via_alias": false, "resolved_name": "EXTERNAL_CRM_URL",
"value_length": 41, "suffix4": "e.co" },
{ "name": "EXTERNAL_CRM_SERVICE_ROLE_KEY", "present": true,
"source": "db", "via_alias": false, "resolved_name": "EXTERNAL_CRM_SERVICE_ROLE_KEY",
"value_length": 219, "suffix4": "x9wA" },
{ "name": "EXTERNAL_CRM_ANON_KEY", "present": true,
"source": "env", "via_alias": true, "resolved_name": "CRM_SUPABASE_ANON_KEY",
"value_length": 219, "suffix4": "g4V0" }
]
}
```

| `health` | Significado | Ação |
|---|---|---|
| `healthy` | URL + 1 key presentes | nenhuma |
| `degraded` | só URL ou só key | cadastrar a parte faltante |
| `missing` | sem URL | bridge fora do ar — cadastrar credenciais |

---

## 📊 Logs Estruturados

### `connections-auto-test`

| Evento | Payload |
|---|---|
| `auto-test` | `{type, name, ok, status, latency_ms, attempts, retried, error}` |
| `auto-test-summary` | `{tested, ok_count, failed, retried, recovered, duration_ms}` |
| `auto-test-error` | `{id, type, error}` (erro por conexão) |
| `auto-test-fatal` | `{error}` (erro global, batch abortado) |

### `_shared/credentials.ts`

| Evento | Payload |
|---|---|
| `credential_resolved` | `{name, resolved_name, source, has_value, cached, duration_ms, via_alias}` |

> `source: "none"` indica credencial não encontrada — alarme precoce.
> Desabilitar verbosidade: `LOG_CREDENTIAL_RESOLUTION=off` no env.

---

## 🛡️ RLS de `integration_credentials`

Policies aceitam role `admin` **OU** `dev` (alinhado ao `secrets-manager`).
Migration: `20260429163441_align_integration_credentials_rls_with_dev.sql`.

```sql
-- Validar policies
SELECT policyname, cmd FROM pg_policies
WHERE tablename = 'integration_credentials';
-- 4 linhas esperadas: SELECT, INSERT, UPDATE, DELETE
```

---

## 🔁 Edge Functions que dependem de credenciais CRM

| Função | Aliases consumidos | Impacto se faltar |
|---|---|---|
| `crm-db-bridge` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY`, `_ANON_KEY` | front "Carregando..." |
| `quote-sync` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY` | sync de orçamentos pausa |
| `expert-chat` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY` | chat sem contexto de cliente |
| `quote-public-view` | `EXTERNAL_CRM_URL`, `_SERVICE_ROLE_KEY` | link público quebra |

Todas usam `resolveCredential()` — DB-first, env como fallback.

---

## 📚 Referências

- `supabase/functions/_shared/credentials.ts` — SSOT de resolução
- `supabase/migrations/20260429163414_*.sql` — schedule do cron
- `supabase/migrations/20260429163441_*.sql` — RLS para dev
- `docs/RUNBOOK.md` — runbook geral
12 changes: 11 additions & 1 deletion scripts/typecheck-edge-functions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,17 @@ function checkFunction(fn) {
// `deno check` is the dedicated typecheck command. It's faster than
// `deno cache` and doesn't execute code. We pass all files at once so
// shared types within the function are resolved together.
const result = spawnSync("deno", ["check", ...files], {
//
// If the function has a local deno.json (with import map for npm:/jsr:
// bare specifiers), pass it via --config so imports like
// `import { Hono } from "hono"` resolve. Without this, bare specifiers
// fail with: Relative import path "X" not prefixed with / or ./ or ../
const localConfig = join(fnDir, "deno.json");
const args = ["check"];
if (existsSync(localConfig)) args.push("--config", localConfig);
args.push(...files);

const result = spawnSync("deno", args, {
encoding: "utf8",
env: { ...process.env, NO_COLOR: "1" },
});
Expand Down
70 changes: 38 additions & 32 deletions src/components/admin/connections/CredentialsChangedBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2, RefreshCw, X } from "lucide-react";
import { supabase } from "@/integrations/supabase/client";
import { useSecretsManager } from "@/hooks/useSecretsManager";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useCallback, useEffect, useRef, useState } from 'react';
import { Loader2, RefreshCw, X } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useSecretsManager } from '@/hooks/useSecretsManager';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';

interface CredentialsChangedBannerProps {
/** Callback executed in parallel with cache invalidation + secret list refresh. */
Expand All @@ -12,7 +12,7 @@ interface CredentialsChangedBannerProps {

interface PendingChange {
count: number;
lastEvent: "INSERT" | "UPDATE" | "DELETE";
lastEvent: 'INSERT' | 'UPDATE' | 'DELETE';
lastName: string | null;
lastAt: number;
}
Expand All @@ -35,16 +35,19 @@ export function CredentialsChangedBanner({ onRefreshed }: CredentialsChangedBann

useEffect(() => {
const channel = supabase
.channel("admin-conexoes-credentials-changes")
.channel('admin-conexoes-credentials-changes')
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "integration_credentials" },
'postgres_changes',
{ event: '*', schema: 'public', table: 'integration_credentials' },
(payload) => {
const event = payload.eventType as "INSERT" | "UPDATE" | "DELETE";
const event = payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE';
// Coluna real em integration_credentials é `secret_name` —
// typo histórico (`name`) escondia o nome do secret alterado
// no aviso de auto-refresh.
const row =
(payload.new as { name?: string } | null) ??
(payload.old as { name?: string } | null);
const name = row?.name ?? null;
(payload.new as { secret_name?: string } | null) ??
(payload.old as { secret_name?: string } | null);
const name = row?.secret_name ?? null;
Comment on lines +48 to +50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

DELETE events can still hide the changed secret_name.

Using payload.new ?? payload.old can prefer an empty new object on deletes, so the old row name is dropped.

Suggested fix
-          const row =
-            (payload.new as { secret_name?: string } | null) ??
-            (payload.old as { secret_name?: string } | null);
+          const row =
+            event === 'DELETE'
+              ? (payload.old as { secret_name?: string } | null)
+              : ((payload.new as { secret_name?: string } | null) ??
+                (payload.old as { secret_name?: string } | null));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(payload.new as { secret_name?: string } | null) ??
(payload.old as { secret_name?: string } | null);
const name = row?.secret_name ?? null;
event === 'DELETE'
? (payload.old as { secret_name?: string } | null)
: ((payload.new as { secret_name?: string } | null) ??
(payload.old as { secret_name?: string } | null));
const name = row?.secret_name ?? null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/admin/connections/CredentialsChangedBanner.tsx` around lines
48 - 50, The current logic in CredentialsChangedBanner (using (payload.new as
...) ?? (payload.old as ...)) can pick an empty payload.new on DELETE and lose
the old secret_name; change the selection to prefer payload.old for DELETE
events or to fall back to payload.old when payload.new is empty. Update the code
that computes row/name (the payload, row, and name variables and any place
reading secret_name) to either check payload.action === 'DELETE' and use
payload.old, or use a check like "use payload.new if it has keys/secret_name
else payload.old" so the old secret_name isn't dropped.

setPending((prev) => ({
count: (prev?.count ?? 0) + 1,
lastEvent: event,
Expand All @@ -71,19 +74,19 @@ export function CredentialsChangedBanner({ onRefreshed }: CredentialsChangedBann
Promise.resolve().then(() => onRefreshedRef.current?.()),
]);
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
const cacheOk = cacheRes.status === "fulfilled" && cacheRes.value.ok;
const listOk = listRes.status === "fulfilled";
const hookOk = hookRes.status === "fulfilled";
const cacheOk = cacheRes.status === 'fulfilled' && cacheRes.value.ok;
const listOk = listRes.status === 'fulfilled';
const hookOk = hookRes.status === 'fulfilled';
const credCount = listOk && Array.isArray(listRes.value) ? listRes.value.length : 0;
const okCount = [cacheOk, listOk, hookOk].filter(Boolean).length;

if (okCount === 3) {
toast.success("Status e cards atualizados", {
toast.success('Status e cards atualizados', {
description: `Cache invalidado · ${credCount} credenciais relidas (${elapsed}s)`,
});
setPending(null);
} else {
toast.warning("Atualização parcial", {
toast.warning('Atualização parcial', {
description: `Algumas operações falharam (${elapsed}s)`,
});
}
Expand All @@ -95,27 +98,30 @@ export function CredentialsChangedBanner({ onRefreshed }: CredentialsChangedBann
if (!pending) return null;

const eventLabel =
pending.lastEvent === "INSERT"
? "criada"
: pending.lastEvent === "DELETE"
? "removida"
: "alterada";
pending.lastEvent === 'INSERT'
? 'criada'
: pending.lastEvent === 'DELETE'
? 'removida'
: 'alterada';

const description =
pending.count === 1
? `Credencial ${pending.lastName ? `"${pending.lastName}" ` : ""}${eventLabel} no banco.`
: `${pending.count} alterações detectadas em credenciais (última: ${pending.lastName ?? "—"}, ${eventLabel}).`;
? `Credencial ${pending.lastName ? `"${pending.lastName}" ` : ''}${eventLabel} no banco.`
: `${pending.count} alterações detectadas em credenciais (última: ${pending.lastName ?? '—'}, ${eventLabel}).`;

return (
<div
role="status"
aria-live="polite"
className="flex items-center gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-2.5 text-sm"
>
<RefreshCw className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
<div className="flex-1 min-w-0">
<RefreshCw
className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground">Credenciais alteradas no banco</p>
<p className="text-xs text-muted-foreground truncate">{description}</p>
<p className="truncate text-xs text-muted-foreground">{description}</p>
</div>
<Button
type="button"
Expand All @@ -126,11 +132,11 @@ export function CredentialsChangedBanner({ onRefreshed }: CredentialsChangedBann
aria-label="Recarregar status e cards agora"
>
{isRefreshing ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-1" />
<RefreshCw className="mr-1 h-4 w-4" />
)}
{isRefreshing ? "Atualizando…" : "Atualizar agora"}
{isRefreshing ? 'Atualizando…' : 'Atualizar agora'}
</Button>
<Button
type="button"
Expand Down
Loading
Loading