feat(G1-03): governance invariants suite — 7 eixos, 22 green + 7 GAP ratchets [gov-loop]

tests/invariants/gov-1..7: per-eixo invariants against disposable Postgres;
it.fails ratchets flag known gaps (G2-G6). Spec 13 Apêndice A filled.
Verified-by: gov-verifier PASS 2026-07-16T19:48-03:00
This commit is contained in:
Rafael Melgaço
2026-07-16 19:47:49 -03:00
parent 35899eab65
commit f7a6b3c83d
12 changed files with 600 additions and 3 deletions
+30 -1
View File
@@ -117,7 +117,36 @@ Aprovação do checkpoint G6 é o gatilho da fase FG do Vendaval.
## Apêndice A — Invariantes de governança (G1-03)
_Preenchido por G1-03: tabela eixo → invariante → status (passa | GAP Gx)._
Suíte executável em `tests/invariants/gov-*.test.ts` (1 arquivo por eixo),
rodada por `pnpm test:invariants` (mesmo harness Postgres descartável do
`pnpm test:db` — `scripts/test-db.sh`). Gap conhecido = `it.fails` com
comentário `GAP(Gx)`: passa enquanto o gap existe; quando a fase corrigir, o
`it.fails` quebra e obriga o flip para teste normal (catraca). Isolamento RLS
entre orgs (pré-requisito de tudo) já é coberto por
`tests/invariants/rls-isolation.test.ts` (G1-02).
| Eixo | Invariante (arquivo → teste) | Status |
|---|---|---|
| 1. RBAC | `gov-1-rbac.test.ts` → "fn_role_at_least ordena viewer < agent < manager < admin" | passa |
| 1. RBAC | `gov-1-rbac.test.ts` → "fn_user_role_in mapeia viewer→1, agent→2, manager→3, admin→4" | passa |
| 1. RBAC | `gov-1-rbac.test.ts` → "RLS impede agent de se auto-promover (user_orgs_update é admin-only)" | passa |
| 1. RBAC | `gov-1-rbac.test.ts` → "role de membro é editável via API — PATCH /api/v1/team/[user_id]/role existe" (gap do plano JÁ fechado pelo EPIC-09) | passa |
| 1. RBAC | `gov-1-rbac.test.ts` → "agent NÃO escreve config de pipeline (spec 13 §4: manager+)" | GAP G2 |
| 1. RBAC | `gov-1-rbac.test.ts` → "viewer NÃO escreve em conversations (spec 13 §4: viewer é read-only)" | GAP G2 |
| 2. Atribuição | `gov-2-assignment.test.ts` → "conversations tem assigned_to_user_id + assigned_at, com FK para auth.users" | passa |
| 2. Atribuição | `gov-2-assignment.test.ts` → "crm_leads tem owner_user_id" | passa |
| 2. Atribuição | `gov-2-assignment.test.ts` → "mudança de owner em crm_leads emite lead.assigned no event_log" | passa |
| 3. Transferência | `gov-3-transfer.test.ts` → "claim atômico: UPDATE condicional atribui 1x; segundo claim concorrente perde" | passa |
| 3. Transferência | `gov-3-transfer.test.ts` → "transferência é auditada: tabela conversation_assignment_events existe" | GAP G3 |
| 4. Roteamento/fila | `gov-4-routing.test.ts` → "índice parcial idx_conversations_open_unassigned existe (base da fila)" | passa |
| 4. Roteamento/fila | `gov-4-routing.test.ts` → "disponibilidade por atendente: tabela attendant_availability existe" | GAP G5 |
| 5. Escopo | `gov-5-visibility-scope.test.ts` → "agent vê conversa atribuída a si mesmo (controle positivo)" | passa |
| 5. Escopo | `gov-5-visibility-scope.test.ts` → "agent NÃO vê conversa atribuída a outro agent (spec 13 §4: own*)" | GAP G4 |
| 6. Handoff IA | `gov-6-ai-handoff.test.ts` → "colunas de handoff do estado atual existem (bot_silenced_until, last_handoff_*, force_human)" | passa |
| 6. Handoff IA | `gov-6-ai-handoff.test.ts` → "status 'ai_handling' é aceito pelo check de conversations.status" | passa |
| 6. Handoff IA | `gov-6-ai-handoff.test.ts` → "conversations.assignee_kind ('user'\|'ai') existe" | GAP G6 |
| 7. Tags | `gov-7-tags.test.ts` → "contacts.tags e crm_leads.tags existem com índice GIN" | passa |
| 7. Tags | `gov-7-tags.test.ts` → "conversations.tags text[] existe" | GAP G3 |
## Apêndice B — Auditoria spec 04/05 vs código (G1-04)
+1
View File
@@ -21,6 +21,7 @@
"test:e2e": "playwright test",
"test:unit": "vitest run",
"test:db": "bash scripts/test-db.sh",
"test:invariants": "bash scripts/test-db.sh",
"gov:verify": "pnpm typecheck && pnpm lint && pnpm test:unit"
},
"dependencies": {
+7 -2
View File
@@ -77,8 +77,13 @@
"priority": 30,
"lane": "core",
"kind": "build",
"passes": false,
"verification": null
"passes": true,
"verification": {
"verdict": "PASS",
"by": "gov-verifier",
"at": "2026-07-16T19:48:00-0300",
"commit": "self"
}
},
{
"id": "G1-04",
+12
View File
@@ -60,3 +60,15 @@
idempotente ("already exists" tolerado no update) — melhoria possível, não bug.
- Próxima sessão: G1-03 (suíte de invariantes dos 7 eixos) ou G1-04 (auditoria
de gap, sem deps) — G1-03 tem priority menor (30 < 40).
## 2026-07-16 — sessão 4 do loop (core) — G1-03
- G1-03 (suíte de invariantes dos 7 eixos): 8 arquivos em tests/invariants/
(gov-helpers + gov-1..7), 29 testes no total — 22 verdes + 7 catracas it.fails
com GAP(Gx). `pnpm test:invariants` = alias do harness test-db.
- Desvio aceito pelo verifier: gap-exemplo "role não editável via API" JÁ estava
fechado (rota do EPIC-09 em app/api/v1/team/[user_id]/role) → virou invariante
verde; gaps RBAC reais de G2: pipeline write por agent, conversations write
por viewer. Catraca provada em probe (it.fails de assert válido → suíte RED).
- gov-verifier: PASS, hash-check OK. Apêndice A da spec 13 preenchido (20 linhas).
- Próxima sessão: G1-04 (auditoria de gap specs 04/05 vs código — sem deps).
+113
View File
@@ -0,0 +1,113 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import {
GOV_AGENT_A,
GOV_ADMIN,
GOV_CONTACT_PROBE,
GOV_MANAGER,
GOV_ORG,
GOV_PIPELINE,
GOV_SESSION,
GOV_VIEWER,
lastLine,
seedGov,
sql,
writeCountAs,
} from "./gov-helpers";
/**
* Eixo 1 — RBAC (spec 13 §1; fase que fecha: G2).
* docs/specs/13-spec-governanca-atendimento.md — dor: "atendente com
* privilégios de owner; nível de acesso não-editável pós-atribuição;
* enforcement só no frontend". Matriz alvo em spec 13 §4.
*/
beforeAll(() => {
seedGov();
});
/** fn_role_at_least(GOV_ORG, threshold) for the user, as '1'/'0' per threshold. */
function roleVector(userId: string): string {
const thresholds = ["viewer", "agent", "manager", "admin"];
const expr = thresholds
.map((t) => `public.fn_role_at_least('${GOV_ORG}', '${t}')::int::text`)
.join(" || ',' || ");
return lastLine(
sql(`
select set_config('request.jwt.claims', '{"sub":"${userId}"}', false);
select ${expr};
`),
);
}
describe("eixo 1 — RBAC", () => {
it("fn_role_at_least ordena viewer < agent < manager < admin", () => {
expect(roleVector(GOV_VIEWER)).toBe("1,0,0,0");
expect(roleVector(GOV_AGENT_A)).toBe("1,1,0,0");
expect(roleVector(GOV_MANAGER)).toBe("1,1,1,0");
expect(roleVector(GOV_ADMIN)).toBe("1,1,1,1");
});
it("fn_user_role_in mapeia viewer→1, agent→2, manager→3, admin→4", () => {
const rank = (userId: string): string =>
lastLine(
sql(`
select set_config('request.jwt.claims', '{"sub":"${userId}"}', false);
select public.fn_user_role_in('${GOV_ORG}')::text;
`),
);
expect(rank(GOV_VIEWER)).toBe("1");
expect(rank(GOV_AGENT_A)).toBe("2");
expect(rank(GOV_MANAGER)).toBe("3");
expect(rank(GOV_ADMIN)).toBe("4");
});
it("RLS impede agent de se auto-promover (user_orgs_update é admin-only)", () => {
const updated = writeCountAs(
GOV_AGENT_A,
`update public.user_organizations set role = 'admin'
where user_id = '${GOV_AGENT_A}' and organization_id = '${GOV_ORG}'`,
);
expect(updated).toBe(0);
const role = sql(
`select role from public.user_organizations where user_id = '${GOV_AGENT_A}' and organization_id = '${GOV_ORG}';`,
);
expect(role).toBe("agent");
});
// Listado no plano como gap conhecido ("role de membro não é editável via
// API" → GAP G2), mas a rota JÁ existe (EPIC-09: PATCH
// app/api/v1/team/[user_id]/role, admin-only + proteção de último admin).
// Gap já fechado ⇒ registrado como invariante VERDE — um it.fails aqui
// seria desonesto e quebraria a suíte.
it("role de membro é editável via API — PATCH /api/v1/team/[user_id]/role existe com export PATCH", () => {
const route = path.resolve(process.cwd(), "app/api/v1/team/[user_id]/role/route.ts");
expect(existsSync(route)).toBe(true);
expect(readFileSync(route, "utf8")).toContain("export async function PATCH");
});
// GAP(G2): spec 13 §4 — pipelines (config) é manager+:write, agent=none.
// Hoje a policy tenant_isolation_crm_pipelines_all é org-flat: qualquer
// membro (incl. agent) escreve config de pipeline.
it.fails("agent NÃO escreve config de pipeline (spec 13 §4: manager+)", () => {
const updated = writeCountAs(
GOV_AGENT_A,
`update public.crm_pipelines set name = name where id = '${GOV_PIPELINE}'`,
);
expect(updated).toBe(0);
});
// GAP(G2): spec 13 §4 — viewer é read-only em conversations. Hoje a policy
// org-flat (WITH CHECK por org) deixa o viewer inserir/escrever.
it.fails("viewer NÃO escreve em conversations (spec 13 §4: viewer é read-only)", () => {
const inserted = writeCountAs(
GOV_VIEWER,
`insert into public.conversations (id, organization_id, contact_id, channel_session_id, status)
values ('cccccccc-4444-4000-8000-000000000099', '${GOV_ORG}', '${GOV_CONTACT_PROBE}', '${GOV_SESSION}', 'open')
on conflict do nothing`,
);
expect(inserted).toBe(0);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { beforeAll, describe, expect, it } from "vitest";
import {
GOV_AGENT_A,
GOV_LEAD,
GOV_ORG,
columnExists,
seedGov,
sql,
} from "./gov-helpers";
/**
* Eixo 2 — Atribuição (spec 13 §1; fase que fecha: G3).
* docs/specs/13-spec-governanca-atendimento.md — dor: "lead/conversa sem
* registro de quem atende; card sem responsável". Estado atual inventariado
* na spec 13 §2 (assigned_to_user_id / owner_user_id).
*/
beforeAll(() => {
seedGov();
});
describe("eixo 2 — atribuição", () => {
it("conversations tem assigned_to_user_id + assigned_at, com FK para auth.users", () => {
expect(columnExists("conversations", "assigned_to_user_id")).toBe(true);
expect(columnExists("conversations", "assigned_at")).toBe(true);
const fk = sql(
`select exists(select 1 from pg_constraint where conname = 'conversations_assigned_to_user_id_fkey');`,
);
expect(fk).toBe("t");
});
it("crm_leads tem owner_user_id (responsável de 1ª classe no card)", () => {
expect(columnExists("crm_leads", "owner_user_id")).toBe(true);
});
it("mudança de owner em crm_leads emite lead.assigned no event_log (trigger, nunca HTTP)", () => {
sql(
`update public.crm_leads set owner_user_id = '${GOV_AGENT_A}' where id = '${GOV_LEAD}';`,
);
const events = Number(
sql(
`select count(*) from public.event_log
where organization_id = '${GOV_ORG}'
and event_type = 'lead.assigned'
and payload ->> 'lead_id' = '${GOV_LEAD}';`,
),
);
expect(events).toBeGreaterThanOrEqual(1);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { beforeAll, describe, expect, it } from "vitest";
import {
GOV_AGENT_A,
GOV_AGENT_B,
GOV_CONV_CLAIM,
seedGov,
tableExists,
writeCountAs,
} from "./gov-helpers";
/**
* Eixo 3 — Transferência (spec 13 §1; fase que fecha: G3).
* docs/specs/13-spec-governanca-atendimento.md — dor: "assumir/transferir com
* erro, sem auditoria". Claim atômico já especificado na spec 04 §9; auditoria
* de mudança de dono é a tabela conversation_assignment_events (spec 13 §3).
*/
beforeAll(() => {
seedGov();
});
describe("eixo 3 — transferência", () => {
it("claim atômico: UPDATE condicional atribui 1x; segundo claim concorrente perde (0 rows)", () => {
// Spec 04 §9: claim = UPDATE ... where assigned_to_user_id is null (o
// perdedor recebe 0 rows e a rota devolve 409).
const first = writeCountAs(
GOV_AGENT_A,
`update public.conversations
set assigned_to_user_id = '${GOV_AGENT_A}', assigned_at = now(), status = 'claimed'
where id = '${GOV_CONV_CLAIM}' and assigned_to_user_id is null`,
);
expect(first).toBe(1);
const second = writeCountAs(
GOV_AGENT_B,
`update public.conversations
set assigned_to_user_id = '${GOV_AGENT_B}', assigned_at = now(), status = 'claimed'
where id = '${GOV_CONV_CLAIM}' and assigned_to_user_id is null`,
);
expect(second).toBe(0);
});
// GAP(G3): transferência não gera evento de auditoria — a tabela
// conversation_assignment_events (spec 13 §3: org_id, conversation_id,
// from/to, changed_by, reason claim|transfer|release|routing|handoff)
// ainda não existe.
it.fails("transferência é auditada: tabela conversation_assignment_events existe (spec 13 §3)", () => {
expect(tableExists("conversation_assignment_events")).toBe(true);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { beforeAll, describe, expect, it } from "vitest";
import { indexExists, seedGov, tableExists } from "./gov-helpers";
/**
* Eixo 4 — Roteamento/fila (spec 13 §1; fase que fecha: G5).
* docs/specs/13-spec-governanca-atendimento.md — dor: "sem fila, sem horário
* por atendente, sem modo configurável, sem painel". Modelo alvo na spec 13
* §3/§5 (attendant_availability, organizations.settings.routing, worker via
* event_log).
*/
beforeAll(() => {
seedGov();
});
describe("eixo 4 — roteamento/fila", () => {
it("base da fila de não-atribuídas existe: índice parcial idx_conversations_open_unassigned (spec 04 §8.3)", () => {
expect(indexExists("idx_conversations_open_unassigned")).toBe(true);
});
// GAP(G5): sem disponibilidade/horário/capacidade por atendente — a tabela
// attendant_availability (spec 13 §3: is_available, capacity, schedule
// jsonb tz-aware) ainda não existe.
it.fails("disponibilidade por atendente: tabela attendant_availability existe (spec 13 §3)", () => {
expect(tableExists("attendant_availability")).toBe(true);
});
});
@@ -0,0 +1,40 @@
import { beforeAll, describe, expect, it } from "vitest";
import {
GOV_AGENT_A,
GOV_AGENT_B,
GOV_CONV_AGENT_B,
countAs,
seedGov,
} from "./gov-helpers";
/**
* Eixo 5 — Escopo de visualização (spec 13 §1; fase que fecha: G4).
* docs/specs/13-spec-governanca-atendimento.md — dor: '"select sem where":
* atendente vê tudo; métricas sem filtro por responsável'. Matriz alvo na
* spec 13 §4 (agent = own*; visibility_mode em §3).
*/
beforeAll(() => {
seedGov();
});
describe("eixo 5 — escopo de visualização", () => {
it("agent vê conversa atribuída a si mesmo (controle positivo — vale hoje e pós-G4)", () => {
const own = countAs(
GOV_AGENT_B,
`select count(*) from public.conversations where id = '${GOV_CONV_AGENT_B}';`,
);
expect(own).toBe(1);
});
// GAP(G4): a RLS de conversations é org-flat — agent A enxerga a conversa
// atribuída ao agent B da mesma org. Spec 13 §4: agent = own*:read+write.
it.fails("agent NÃO vê conversa atribuída a outro agent (spec 13 §4: agent = own*)", () => {
const crossAgent = countAs(
GOV_AGENT_A,
`select count(*) from public.conversations where id = '${GOV_CONV_AGENT_B}';`,
);
expect(crossAgent).toBe(0);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { beforeAll, describe, expect, it } from "vitest";
import { columnExists, seedGov, sql } from "./gov-helpers";
/**
* Eixo 6 — Handoff IA→humano (spec 13 §1; fase que fecha: G6, + fase FG do
* Vendaval). docs/specs/13-spec-governanca-atendimento.md — dor: "IA não sabe
* direcionar para humano disponível/fila". Semântica de handoff na spec 05;
* alvo assignee_kind ('user'|'ai') na spec 13 §3.
*/
beforeAll(() => {
seedGov();
});
describe("eixo 6 — handoff IA→humano", () => {
it("colunas de handoff do estado atual existem (spec 13 §2)", () => {
expect(columnExists("conversations", "bot_silenced_until")).toBe(true);
expect(columnExists("conversations", "last_handoff_at")).toBe(true);
expect(columnExists("conversations", "last_handoff_reason")).toBe(true);
expect(columnExists("contacts", "force_human")).toBe(true);
});
it("status 'ai_handling' é aceito pelo check de conversations.status", () => {
const def = sql(
`select pg_get_constraintdef(oid) from pg_constraint where conname = 'conversations_status_check';`,
);
expect(def).toContain("ai_handling");
});
// GAP(G6): handoff ainda não é reassignment auditado de 1ª classe — a
// coluna conversations.assignee_kind ('user'|'ai', spec 13 §3) não existe;
// quem atende (humano vs IA) segue ambíguo entre status e ai_handling.
it.fails("conversations.assignee_kind ('user'|'ai') existe (spec 13 §3)", () => {
expect(columnExists("conversations", "assignee_kind")).toBe(true);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { beforeAll, describe, expect, it } from "vitest";
import { columnExists, indexExists, seedGov } from "./gov-helpers";
/**
* Eixo 7 — Tags (spec 13 §1; fase que fecha: G3).
* docs/specs/13-spec-governanca-atendimento.md — dor: "origem/categoria/
* etiquetas ausentes ou não-filtráveis". Padrão do repo: tags text[] + GIN
* (CLAUDE.md §Modelagem); alvo conversations.tags na spec 13 §3.
*/
beforeAll(() => {
seedGov();
});
describe("eixo 7 — tags", () => {
it("contacts.tags e crm_leads.tags existem com índice GIN (padrão filtrável)", () => {
expect(columnExists("contacts", "tags")).toBe(true);
expect(columnExists("crm_leads", "tags")).toBe(true);
expect(indexExists("idx_contacts_tags_gin")).toBe(true);
expect(indexExists("idx_crm_leads_tags_gin")).toBe(true);
});
// GAP(G3): conversas não são etiquetáveis — conversations.tags text[]
// (spec 13 §3, mesmo padrão de contacts/leads) não existe.
it.fails("conversations.tags text[] existe (spec 13 §3)", () => {
expect(columnExists("conversations", "tags")).toBe(true);
});
});
+201
View File
@@ -0,0 +1,201 @@
import { execFileSync } from "node:child_process";
/**
* G1-03 — shared harness for the governance invariants (gov-*.test.ts).
*
* Same docker-exec-psql pattern as rls-isolation.test.ts (G1-02): the suite
* runs against the ephemeral Postgres started by scripts/test-db.sh
* (baseline.sql applied), with JWT claims simulated via
* set_config('request.jwt.claims', ...) — the exact auth.uid() path the
* production RLS policies use. No real PII anywhere (LGPD): synthetic
* @invariant.test emails only.
*/
const container = process.env.TEST_DB_CONTAINER;
if (!container) {
throw new Error(
"TEST_DB_CONTAINER not set — run this suite via `pnpm test:invariants` (scripts/test-db.sh)",
);
}
const containerName: string = container;
/** Runs a SQL script in ONE psql session inside the container; returns stdout (tuples-only). */
export function sql(script: string): string {
return execFileSync(
"docker",
[
"exec",
"-i",
containerName,
"psql",
"-U",
"postgres",
"-d",
"postgres",
"-v",
"ON_ERROR_STOP=1",
"-tA",
"-f",
"-",
],
{ input: script, encoding: "utf8" },
).trim();
}
/** Last stdout line of a script (psql -tA prints one line per SELECT). */
export function lastLine(out: string): string {
const lines = out.split("\n");
const last = lines[lines.length - 1];
if (last === undefined) throw new Error(`empty psql output`);
return last;
}
/**
* Runs a SELECT count as the `authenticated` role with the given user's JWT
* claims — same shape PostgREST/Supabase uses (session role + request.jwt.claims).
*/
export function countAs(userId: string, countQuery: string): number {
const out = sql(`
set role authenticated;
select set_config('request.jwt.claims', '{"sub":"${userId}"}', false);
${countQuery}
`);
const last = lastLine(out);
if (!/^\d+$/.test(last)) throw new Error(`unexpected psql output: ${out}`);
return Number(last);
}
/**
* Runs a DML (no trailing `;`, no RETURNING — the helper appends `returning 1`)
* as the `authenticated` role with the user's claims; returns affected rows.
* An RLS denial (42501 / with-check violation) counts as 0 rows — the write
* was blocked, which is exactly what the invariant measures. Any other error
* rethrows.
*/
export function writeCountAs(userId: string, dml: string): number {
try {
const out = sql(`
set role authenticated;
select set_config('request.jwt.claims', '{"sub":"${userId}"}', false);
with w as (${dml} returning 1) select count(*) from w;
`);
const last = lastLine(out);
if (!/^\d+$/.test(last)) throw new Error(`unexpected psql output: ${out}`);
return Number(last);
} catch (err) {
const stderr = (err as { stderr?: string }).stderr ?? "";
if (stderr.includes("row-level security")) return 0;
throw err;
}
}
export function tableExists(table: string): boolean {
return (
sql(
`select exists(select 1 from information_schema.tables where table_schema = 'public' and table_name = '${table}');`,
) === "t"
);
}
export function columnExists(table: string, column: string): boolean {
return (
sql(
`select exists(select 1 from information_schema.columns where table_schema = 'public' and table_name = '${table}' and column_name = '${column}');`,
) === "t"
);
}
export function indexExists(index: string): boolean {
return (
sql(
`select exists(select 1 from pg_indexes where schemaname = 'public' and indexname = '${index}');`,
) === "t"
);
}
// Fixed UUIDs (cccccccc- namespace; rls-isolation uses aaaa/bbbb) make the
// seed idempotent AND race-safe across parallel test files (on conflict do nothing).
export const GOV_ORG = "cccccccc-0000-4000-8000-000000000001";
export const GOV_VIEWER = "cccccccc-1111-4000-8000-000000000001";
export const GOV_AGENT_A = "cccccccc-1111-4000-8000-000000000002";
export const GOV_AGENT_B = "cccccccc-1111-4000-8000-000000000003";
export const GOV_MANAGER = "cccccccc-1111-4000-8000-000000000004";
export const GOV_ADMIN = "cccccccc-1111-4000-8000-000000000005";
export const GOV_SESSION = "cccccccc-2222-4000-8000-000000000001";
// One contact per conversation: uniq_conversations_1to1_per_contact_session
// (migration 0027) allows only ONE 1:1 conversation per (org, contact, session).
export const GOV_CONTACT_1 = "cccccccc-3333-4000-8000-000000000001";
export const GOV_CONTACT_2 = "cccccccc-3333-4000-8000-000000000002";
export const GOV_CONTACT_3 = "cccccccc-3333-4000-8000-000000000003";
/** Contact WITHOUT any conversation — reserved for write-probe inserts. */
export const GOV_CONTACT_PROBE = "cccccccc-3333-4000-8000-000000000004";
/** Unassigned open conversation (read-scope probes). */
export const GOV_CONV_UNASSIGNED = "cccccccc-4444-4000-8000-000000000001";
/** Conversation assigned to GOV_AGENT_B (visibility-scope probes). */
export const GOV_CONV_AGENT_B = "cccccccc-4444-4000-8000-000000000002";
/** Unassigned conversation reserved for the atomic-claim invariant. */
export const GOV_CONV_CLAIM = "cccccccc-4444-4000-8000-000000000003";
export const GOV_PIPELINE = "cccccccc-5555-4000-8000-000000000001";
export const GOV_STAGE = "cccccccc-5555-4000-8000-000000000002";
export const GOV_LEAD = "cccccccc-6666-4000-8000-000000000001";
const ROLE_USERS: ReadonlyArray<readonly [string, string, string]> = [
[GOV_VIEWER, "viewer", "gov-viewer"],
[GOV_AGENT_A, "agent", "gov-agent-a"],
[GOV_AGENT_B, "agent", "gov-agent-b"],
[GOV_MANAGER, "manager", "gov-manager"],
[GOV_ADMIN, "admin", "gov-admin"],
];
/** Idempotent seed: 1 org, 5 members (1 per role + 2nd agent), 3 conversations, 1 lead. */
export function seedGov(): void {
const users = ROLE_USERS.map(
([id, , tag]) =>
`insert into auth.users (id, email) values ('${id}', '${tag}@invariant.test') on conflict do nothing;`,
).join("\n");
const memberships = ROLE_USERS.map(
([id, role]) =>
`insert into public.user_organizations (user_id, organization_id, role, accepted_at)
values ('${id}', '${GOV_ORG}', '${role}', now()) on conflict do nothing;`,
).join("\n");
sql(`
${users}
insert into public.organizations (id, slug, legal_name, display_name)
values ('${GOV_ORG}', 'gov-inv', 'Gov Invariant Org', 'Gov Inv')
on conflict do nothing;
${memberships}
-- DO + exception (não ON CONFLICT): channel_sessions tem unique DEFERRABLE
-- (phone_per_org), que ON CONFLICT sem arbiter rejeita, e o arbiter (id)
-- não cobre a corrida no unique de waha_session_name entre arquivos paralelos.
do $gov$ begin
insert into public.channel_sessions (id, organization_id, waha_session_name, webhook_secret_encrypted)
values ('${GOV_SESSION}', '${GOV_ORG}', 'gov-inv', '\\x00'::bytea);
exception when unique_violation then null; end $gov$;
insert into public.contacts (id, organization_id, display_name)
values
('${GOV_CONTACT_1}', '${GOV_ORG}', 'Gov Invariant Contact 1'),
('${GOV_CONTACT_2}', '${GOV_ORG}', 'Gov Invariant Contact 2'),
('${GOV_CONTACT_3}', '${GOV_ORG}', 'Gov Invariant Contact 3'),
('${GOV_CONTACT_PROBE}', '${GOV_ORG}', 'Gov Invariant Contact Probe')
on conflict do nothing;
insert into public.conversations (id, organization_id, contact_id, channel_session_id, status)
values ('${GOV_CONV_UNASSIGNED}', '${GOV_ORG}', '${GOV_CONTACT_1}', '${GOV_SESSION}', 'open')
on conflict do nothing;
insert into public.conversations (id, organization_id, contact_id, channel_session_id, status, assigned_to_user_id, assigned_at)
values ('${GOV_CONV_AGENT_B}', '${GOV_ORG}', '${GOV_CONTACT_2}', '${GOV_SESSION}', 'claimed', '${GOV_AGENT_B}', now())
on conflict do nothing;
insert into public.conversations (id, organization_id, contact_id, channel_session_id, status)
values ('${GOV_CONV_CLAIM}', '${GOV_ORG}', '${GOV_CONTACT_3}', '${GOV_SESSION}', 'open')
on conflict do nothing;
insert into public.crm_pipelines (id, organization_id, name, slug)
values ('${GOV_PIPELINE}', '${GOV_ORG}', 'Gov Invariant', 'gov-inv')
on conflict do nothing;
insert into public.crm_stages (id, organization_id, pipeline_id, name, slug, position)
values ('${GOV_STAGE}', '${GOV_ORG}', '${GOV_PIPELINE}', 'Novo', 'novo', 1000)
on conflict do nothing;
insert into public.crm_leads (id, organization_id, pipeline_id, stage_id, title)
values ('${GOV_LEAD}', '${GOV_ORG}', '${GOV_PIPELINE}', '${GOV_STAGE}', 'Gov invariant lead')
on conflict do nothing;
`);
}