mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
main
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4d90532ed7 |
fix(waha): mensagem que chega com o banco indisponível não se perde mais
A ingestão do webhook WAHA engolia a falha do banco nos quatro pontos em que a mensagem depende de uma escrita (contato, conversa, mensagem recebida e mensagem enviada pelo celular): `console.error` + `return`, e a rota devolvia 200. O WAHA riscava o evento achando que entregou, e a mensagem do cliente sumia — medido em produção: três perdidas em 14/09 num dia de `statement timeout`, duas na troca de banco de 24/09. - `lib/waha/falha-transitoria.ts`: a régua. Erro de dado, integridade, schema, regra de negócio e pedido malformado são permanentes; o resto (timeout, conexão, PostgREST sem banco, 5xx, sem código) é transitório. A ingestão passa a lançar a classe certa nos quatro pontos. - `lib/waha/desfecho-do-webhook.ts`: as duas rotas gravam o desfecho no arquivo (`processed` / `error`), que até aqui nascia e morria `received`. Falha transitória responde 503 + Retry-After: o WAHA reentrega qualquer erro 15x com espera max(2s, Retry-After), e a reentrega é segura porque o 23505 vira dedup. - `app/api/v1/cron/webhook-replay`: relê a cada minuto o arquivo marcado `transitoria:`, até 20 tentativas; depois `dead` e um aviso `event_dead` na Central com título próprio (`MENSAGEM_QUE_NAO_ENTROU`), que o dreno de handlers exclui do seu dedupe. Para na terceira falha seguida da rodada para não martelar um banco que ainda está voltando. É o `process-pending-webhooks` que o PRD 03 e a Spec 03 prometiam; os dois passam a dizer o nome e a régua reais. Sem migration: `processed` e `dead` já estavam no CHECK, `event_dead` já existia. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
73857d4881 |
docs: a Vercel deixa de aparecer como onde o CRM roda e valida
O projeto do CRM na Vercel foi desvinculado do GitHub por decisao do dono: a plataforma fica so com a landing page, em outro repositorio. Medido com a mesma sonda nos dois lados — `gh api repos/.../commits/<sha>/status` devolve `Vercel` no commit |
||
|
|
d69d708d8d |
feat: concluir jornadas da comunidade — organizações, atendimento, agenda e autonomia (#613)
* feat: complete community workflows for organizations, agents and scheduling
* docs: cite selected community evidence in the journey map
* test(automation): supply event boundary and agenda fixtures for AI sends
* fix: prevent readonly support from changing channel AI access
* test: verify integrated community journeys and secure fixture randomness
* fix(agenda): ocupação do Google sem catálogo volta a contar, e o gatilho do Meet trava na ordem das irmãs
fn_google_counts_for_conflicts exigia linha em calendar_connection_calendars
para um evento externo contar. Os três leitores (grade, semente da página e o
motor de horários livres) passaram a ler a view que a usa, então conexão sem
catálogo montado perdia a ocupação na tela E deixava de bloquear o horário —
o oposto do que os três leitores faziam antes. Passa a falhar ABERTO: só não
conta quem tem linha dizendo counts_for_conflicts=false.
fn_meet_delivery_enqueue travava job_queue segurando a linha do compromisso
sem o mutex do contato; fn_meet_redact_contact (0229) faz a ordem inversa.
Duas ordens opostas sobre os mesmos recursos = 40P01 sob concorrência.
* fix(contatos): fundir duplicado volta a funcionar no caso ordinário
A guarda `mescla_conversas_colidentes`, introduzida em 0222, abortava a fusão
sempre que os contatos do grupo tivessem mais de uma conversa no mesmo
`channel_session_id` — que é EXATAMENTE como a duplicata de WhatsApp nasce
(dois cadastros, dois números, o mesmo número de atendimento). O caminho
dominante do recurso virava 409.
Medido no Postgres da QA, fixture de dois contatos com uma conversa cada na
mesma sessão:
antes: ERROR: mescla_conversas_colidentes
depois: {"repontado": {"messages.contact_id": 1, "demandas.contact_id": 1},
"nao_repontado": {"conversations.contact_id": 1}, ...}
A colisão já tinha dono e não é perda de mensagem: `messages.contact_id` não
tem índice único por contato e passa inteira para o vencedor; quem colide é a
conversa, contra `uniq_conversations_1to1_per_contact_session`, e o passo 5 já
cai para repontamento linha a linha, deixa a conversa na lápide e a CONTA em
`nao_repontado` — que a rota devolve e a tela anuncia. É o desfecho que
`tests/e2e/juntar-contatos-duplicados.spec.ts` trava, com número.
O mutex de atendimento que 0222 trouxe (fn_service_lock + `for no key update`)
fica: o problema nunca foi a ordem de trava, foi a recusa.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ia): rascunho do assistido volta a respeitar dono humano e silêncio
O drain desliga o gate de elegibilidade antes de enfileirar quando a org tem
agente assistido publicado no canal (`canAssist`, drain.ts:307/315). Isso é
deliberado — o rascunho é o produto do modo assistido — e transfere a checagem
inteira para o turno. Só que o ramo assistido de `createInboundTurnHandler`
devolvia ANTES de `runAgentTurn`, que é onde as duas guardas moram
(isLeadInHandoff e decidirElegibilidadeDaConversa). O gêmeo de :1572 está
depois delas e por isso nunca sofreu.
O gate não é só o pré-go-live: a mesma consulta lê `contacts.force_human`,
`conversations.assignee_kind` (dono humano) e `bot_silenced_until`
(consulta-pg.ts:34-48). Na prática, uma conversa que uma PESSOA assumiu seguia
recebendo rascunho do robô.
As guardas entram DENTRO do ramo assistido, não antes dele: o caminho
automático já as refaz em `runAgentTurn`, e antecipá-las custaria duas queries
por turno sem mudar desfecho nenhum. Handoff falha fechado; falha da consulta
de elegibilidade degrada aberto, igual ao gêmeo.
tests/unit/assistido-respeita-o-gate.test.ts cobre os três motivos da regra
pura, o handoff, o controle positivo (sem ele, um `return` cedo demais deixaria
tudo verde por ausência) e a degradação aberta.
npx vitest run tests/unit/assistido-respeita-o-gate.test.ts
Test Files 1 passed (1) / Tests 6 passed (6)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(agenda): o veredito de I3 sai de cima do relógio
googlePushCandidates filtra `google_next_attempt_at <= now`, com `now` no
relógio do PROCESSO (new Date()) e a coluna no default `now()` do BANCO, que
aqui roda num container. O veredito de I3 passava a depender de os dois
relógios concordarem na casa dos milissegundos.
Medido nesta máquina, 30 amostras: o lote selecionado tem SEMPRE 1 linha (a
`healthy`), os 50 vínculos redigidos estão sempre fora, e a folga entre os dois
relógios é de 6 a 204 ms. Sabotagem de 5 ms (`now()+interval '5 milliseconds'`
na fixture) reproduz na hora o vermelho do CI — `:377:61 expected false to be
true`, 1 vermelho em 2 rodadas. A fixture passa a gravar o instante um minuto
no passado pelo relógio do próprio banco: tolera qualquer desvio abaixo de 60s
e não afrouxa nada do que I3 mede.
⚠️ freeze-invariants.sh contornado com DESKCOMM_GOV_INVARIANTS_EDIT=1, e o
motivo é que o congelamento não alcança este arquivo: ele NASCE neste PR
(ausente em
|
||
|
|
c7e5308af7 |
fix(295): a doutrina acompanha o detector novo, e o conserto do modelo sobrevive ao refetch
Consertos de triagem sobre o #295. Os cinco consertos dele ficam — medi um a um e os cinco são reais (a conta fecha: cinco afirmados, cinco entregues). ── 1. DoD 16: quatro documentos descreviam um detector que deixou de existir ─── `git diff --name-only origin/main...pr/295 -- docs/ CLAUDE.md '*.md'` era VAZIO, e depois do merge estas quatro afirmações ficariam falsas em documento de autoridade — o modo de falha nº 1 da triagem, e o motivo de o DoD 16 existir (a auditoria de 2026-08-14 achou 227 afirmações desatualizadas em 393): CLAUDE.md:92 · docs/business-rules/…:172 (W-02, hard constraint) · docs/prd/03-…:231 · docs/research/reference-synthesis.md:121 Todas trocadas seguindo a receita do próprio CLAUDE.md — "onde a afirmação puder virar comando, troque em vez de corrigir". Em vez de recopiar uma regex nova que envelhece igual, elas passam a apontar para `lib/opt-out/deteccao.ts` e para as frases de controle do teste, com o comando que as lê. ── 2. Uma DECISÃO REGISTRADA estava sendo revertida em silêncio ─────────────── `ADR-EPIC03-07` diz literalmente: "STOP detection é match exato regex (palavra isolada). **Frases livres NÃO bloqueiam**". O #295 inverte isso — "não quero mais receber", "me tira da lista" e "cancelar inscrição" passam a bloquear. A reversão é a coisa CERTA (a regra antiga deixava passar 21 de 33 pedidos reais, e opt-out é direito legal num produto vendido como LGPD-nativa), mas o CLAUDE.md exige abrir item de revisão, não sobrescrever. A ADR-07 fica marcada como supersedida, com o porquê, e entra a **ADR-EPIC03-07b**. E há uma ironia que vale registrar: a ADR-07 existia para evitar falso positivo, e a implementação nunca foi "match exato" — a regex caçava a palavra em qualquer posição. Ela produzia exatamente o falso positivo que a decisão queria evitar. A decisão escrita e o código nunca coincidiram. ── 3. O conserto nº 4 ("modelo em vigor") não sobrevivia ao primeiro refetch ─── O PR acrescentou o join `versao_publicada` ao Server Component, mas a lista é re-hidratada por `useAgentsList` → `GET /api/v1/ai/agents`, cujo `AGENT_COLUMNS` não tinha o join. O cartão voltava a mostrar o id do CADASTRO na primeira revalidação. Duas fontes para a mesma lista têm de pedir as mesmas colunas. O join entra numa constante SEPARADA, usada só na listagem: pedi-lo no POST faz o tipo da linha recém-inserida deixar de resolver (`GenericStringError` — reproduzido, `tsc` acusou nas linhas 158 e 170), e é coerente, porque agente recém-criado tem `published_version_id = null` por construção. ── 4. A religação do runtime não era vigiada por NENHUM dos 5123 testes ─────── Medido: revertendo só `lib/agent-engine/agent/human-handoff.ts` para a versão antiga — que reintroduz as duas regras divergentes cuja unificação é o conserto nº 1 — a suíte inteira fica VERDE (458 arquivos, 5123 casos, exit 0). O PR protegeu o outro lado (`pos-entrada.ts` tem assertiva de import), e este ficou descoberto. Dois casos de COMPORTAMENTO, não de texto — as frases só respondem certo pela regra nova. Sabotado: revertendo o arquivo, 1 de 46 falha; restaurado, 46/46. ── 5. Dois consertos menores ────────────────────────────────────────────────── - O dublê do SELECT em `arquivar-agente-arquiva-mesmo` tinha aridade FIXA de dois `.eq()`. Um filtro novo o quebraria com "maybeSingle is not a function", erro que não fala do comportamento vigiado. É a armadilha que mordeu três vezes nesta rodada; virou encadeável sem limite, como o dublê do UPDATE ao lado. - `app/app/ai/agents/page.tsx` descartava o `error` do SELECT — e o join novo acrescentou uma causa de erro a ele. "Não consegui perguntar" e "você não tem agente nenhum" pintavam a MESMA tela, e a segunda é uma afirmação forte sobre o trabalho de quem instalou. Continua degradando para lista vazia (a tela não pode quebrar), mas agora deixa rastro. ── Verificação ─────────────────────────────────────────────────────────────── `tsc --noEmit` exit 0 · `pnpm lint:channels` ok · 88 casos verdes nas seis suítes tocadas pelo PR. NÃO MEDIDO: nada pela tela. Os itens de UI foram verificados por código, teste e sonda de componente — não por navegador em ambiente fresco (DoD 12). |
||
|
|
4413b7fdb0 |
fix(pacing): domingo deixa de vetar por default — e a regra ganha a guarda que nunca teve
Decisão do dono do produto (2026-08-20). A janela horária é CORTESIA, não anti-banimento — a distinção já está no código, no `banRisk` de `pacing/engine.ts`, que desarma warm-up, cap e throttle mas mantém a janela em todo canal. Calar o domingo INTEIRO num CRM de ATENDIMENTO faz quem escreve no domingo só ser respondido na segunda: o custo cai sobre o cliente final, não sobre o risco de bloqueio. `allowSunday` passa a `true` por default. A janela horária (7h-22h) fica: cala à noite, como antes. E continua sendo knob por canal — quem faz prospecção ativa e prefere não incomodar no fim de semana desliga em Conexões → anti-banimento (`AntiBanSheet` → `POST /api/v1/ai/pacing`, coluna `channel_knobs.allow_sunday`). **A metade do domingo não tinha NENHUMA guarda de comportamento.** Medido antes de mexer: virar o default de `false` para `true` não deixou um único teste vermelho — `pacing-cortesia-vs-antiban` cita "domingo" só num comentário, e os outros dois arquivos que tocam `allow_sunday` passam `null`. Default de regra de negócio sem teste é default que volta sozinho no próximo refactor, então entra `tests/unit/janela-domingo-e-knob.test.ts`, que guarda as DUAS direções: - domingo comercial passa; terça comercial também (controle, para o primeiro não passar por acidente); domingo de madrugada veta por HORA e o motivo não fala em domingo; - com `allowSunday: false` explícito, domingo comercial é vetado com "sem domingo" no motivo, e o reagendamento cai FORA do domingo. Sabotado para provar que vigia: devolvendo `allowSunday: false`, 3 de 6 falham. Restaurado, 6/6. As quatro suítes vizinhas seguem verdes. Doutrina acompanhando o código, não a prosa: W-07 em `docs/business-rules/00-business-rules-catalog.md` reescrita com o porquê e com onde se configura, `docs/prd/03-prd-whatsapp-waha.md:241` (o exemplo era literalmente "domingo 23h"), `CLAUDE.md:91`, e a cópia da tela — que dizia "Desligado por padrão: envio em domingo aumenta o risco de denúncia e bloqueio". |
||
|
|
4aa65590e0 |
feat(orcamento): a tela deixa de mentir, e a IA que para avisa gente
Ondas 4 e 5, que fecham o conserto: o controle da tela passa a valer, o sistema paralelo morto sai, e um bloqueio deixa de abandonar o lead. `DESKCOMM_GOV_INVARIANTS_EDIT=1` — dois invariantes MODIFICADOS, e a razão: `vocabulario-banco-x-typescript` ganhou o par `ModoDeOrcamento` (coluna nova com CHECK precisa de par no TypeScript, senão o job `invariants` reprova), e `orcamento-apos-backfill` acompanhou a mudança do piso. O invariante NOVO (`orcamento-nasce-desarmado`) não precisa da flag. ## Os três achados que valeram mais que a feature **1. A escolta era inalcançável no caso dominante.** `classifyStage` roda em todo turno e estourava ANTES da escolta — que, portanto, nunca protegia o caminho normal. Movida para `runAgentTurn`, que envolve o turno inteiro. A guarda antiga contava `runModelCall(` no texto e era cega para helpers; agora conta call sites e a fronteira dos auxiliares. **2. A escada era contornável pela REST do Supabase** (migration 0160). O PATCH exige `off → avisar → bloquear`, mas `ai_budgets` aceitava escrita direta de `authenticated` — qualquer cliente com a anon key pulava a escada e armava bloqueio imediato. `revoke insert/update/delete`, SELECT fica. **3. O texto mandava clicar num botão que não existe.** A mensagem que o cliente lê quando a IA para dizia "Retomar atendimento automático"; o componente renderiza **"Devolver ao automático"**. Corrigido em 7 arquivos, e a guarda nova **lê o rótulo do componente** em vez de guardar uma cópia — cópia de rótulo é a próxima mentira esperando envelhecer. ## O dropdown decorativo saiu "Ação ao atingir 100%" oferecia "Pausar" vs "Desabilitar" e o produto entregava sempre a mesma coisa. Era o defeito que este trabalho existe para matar, sobrevivendo dentro do conserto — um juiz reprovou um desenho inteiro por mantê-lo. Ou entrega os dois futuros, ou sai. Saiu. ## O lead não fica mais no vácuo Quando o orçamento barra, o turno vai para `performHumanHandoff` com contexto, e o job é **cancelado** (`cancelJob`), não falhado com retry: repetir uma chamada que o teto recusa é gastar de novo para receber a mesma recusa. O caminho legado ganhou o mesmo veto — e depois dos gates que reconhecem "quero falar com um atendente", não antes, senão pedir humano viraria silêncio. ## Sabotagem: 10 previsões, 10 acertos escolta fora do turno 3×3 · piso só para bloquear 3×3 · não zerar a carência 2×2 · veto antes dos gates 1×1 · sem handoff 1×1 · ressalva de medição sumida 1×1 · rótulo do período revertido 2×2 · flag de gasto incompleto fixa 1×1 · janela do mês removida 1×1 · botão renomeado 3×3. ## Uma discordância mantida, com evidência O KPI de plataforma fica na coluna acumulada. A alternativa barata seria um `group by` inline — a segunda régua de gasto que `orcamento-uma-regua-de-gasto` existe para impedir, e o gate provou o ponto reprovando o próprio comentário do implementador quando ele escreveu a query em PROSA. Mitigado: nunca `critical`, rótulo "acumulado", divergência escrita no arquivo e link para a tela que tem a régua certa. ## Bateria typecheck 0 · lint 0 erros · lint:channels 0 · test:shell 0 · build 0 · test:unit **3347 passam**, 1 falha — a baseline do `.env.local` deste worktree, que não cita orçamento, budget, handoff nem money. ## NÃO MEDIDO, declarado - `tests/invariants/orcamento-nasce-desarmado.test.ts` é **novo e nunca foi executado** (15 casos, e é a primeira vez que `SQL_ORCAMENTO` roda contra Postgres em todo o repo). É o maior risco de CI vermelho desta entrega, e está escrito aqui porque dívida declarada não é defeito — dívida presumida é. - O `revoke` da 0160 não foi provado contra PostgREST vivo: o achado veio de leitura de grants. - **DoD 12 (prova pela tela): NÃO MEDIDO.** Radio, kill switch e badge estão provados por unidade em jsdom e por `build`, não por browser em ambiente fresco estilo VPS. - `test:db`, `e2e` e `build-and-size`: quem prova é o CI. Nota de release em `docs/release/teto-de-orcamento.md`; mapa vivo em `docs/architecture/teto-de-orcamento.architecture.json` (26 nós, 43 arestas). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TCiffR3ugjQbxsfceQE2GB |
||
|
|
e74d76735e |
merge: origin/main → feat/followup-flows (integração do sistema de follow-up)
Integra 134 commits da main (inbox-multimodal, split de mensagens, templates, snooze, notes, auth signup/recovery) com o sistema de follow-up (8 ondas). DESKCOMM_GOV_MIGRATION_EDIT=1: falso positivo do guard de migration durante MERGE — o 0055 que ele acusa é da main (entrando via merge), não migration nova. Confirmado zero NNNN duplicado no tree (cada número único; única migration nova é a 0065). Conflitos resolvidos (6, aditivos): Sidebar, agent-inbox-copy, audit/actions, register-handlers, baseline.sql, MANIFEST. Migrations sem colisão de número. Forward-fix 0065: 0057(followup_dead) e 0062(snooze_expired) redefiniam a mesma constraint agent_inbox_items_kind_check — a última vencia; reconciliada com a união. Kit self-host: docker-compose.prod.yml ganha o cron do followup-flow-worker (a cada minuto) — sem ele o motor de follow-up não roda na VPS. Validação: typecheck 0, lint 0, build ok, invariantes 40 arquivos/247 verdes (baseline fresh+update com os 12 apêndices), pnpm-lock reconciliado. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143T3gYRomX8kFxK3KhJK1U |
||
|
|
a4762f846b |
docs(followup): documenta contrato do sistema de follow-up no PRD 05 (DoD) [onda 8]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143T3gYRomX8kFxK3KhJK1U |
||
|
|
f3ff2af40b |
docs(branding): reposicionamento — de CRM de e-commerce para AI Sales OS
De "CRM operacional para e-commerce" para "sistema operacional de vendas open source com agentes de IA, nativo no WhatsApp" — refletindo a adoção multi-nicho da comunidade (clínicas, imobiliárias, infoprodutos, agências) e a especialização em agentes de IA via MCP. - VISION.md novo: fonte da verdade do posicionamento (Deskcomm = Desk + comm, "comercial de mesa"; princípios sobre agentes auto-aprimoráveis; modelo open source + infraestrutura declarado sem letra miúda) - README bilíngue (pt-br primário + README.en.md com seletor de idioma) com hero orientado a benefício e âncora "alternativa aberta a Kommo/Octadesk/Intercom"; bloco HostGator preservado intocado - public/llms.txt: GEO — resumo estruturado pra crawlers de IA - PRD master: nota de transição §0 + sumário/visão/diferenciais/glossário atualizados (e-commerce = primeiro vertical, não definição) - CLAUDE.md e package.json alinhados ao novo posicionamento - deploy-selfhost: tagline atualizada Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARJNvbYiBAtSV5W1rLg5zj |
||
|
|
8cd723b84d |
docs(waha): switch production hosting recommendation Hetzner → Hostgator
Hostgator é parceiro comercial do projeto, justificando preferência sobre Hetzner mesmo com custo maior (~R$140 vs ~$5/mês). Datacenter São Paulo adiciona vantagem real de latência <30ms pro Meta BR (vs ~150ms da EU). - Novo runbook docs/runbooks/waha-hostgator.md (13 seções: bootstrap, Nginx + egress allowlist Vercel CIDRs, restic→B2, watchdog, restore drill) - Specs/PRDs/research/presentation atualizados com plano Turing, custo R$, datacenter SP - Hetzner mantido como plano B documentado (sem parceria) e DR cross-region - Backup: substituído "Hetzner Volume Snapshots" por "restic → Backblaze B2" (Hostgator não tem snapshots nativos — restore drill mensal documentado) |
||
|
|
d0859733d8 |
feat: bootstrap DeskcommCRM v0.1 — full PRD/Specs/scaffold + Supabase schema
DOCS (~85k words): - PRD-Master + 6 Sub-PRDs (platform, customer 360, whatsapp, pipeline, ai, nuvemshop) - 60 Business Rules catalog (T/L/W/P/AT/IA/B prefixes with enforcement layer) - 8 Technical Specs (~60k words) — schema SQL, code patterns, flows - 15 Mermaid architecture diagrams (C4, ER, sequence, state machines) - Pitch deck for SP meeting 2026-04-29 - Reference synthesis from CRM Nichado WAHA bundle CODE SCAFFOLDING (38 files, ~1.7k lines): - Next.js 15 App Router + TypeScript + Tailwind + shadcn/ui - Supabase clients (browser, server, admin) via @supabase/ssr - API wrappers (ok, fail, ApiSuccess<T>, ApiError) + canonical error codes - env.ts with Zod validation; .env.example exhaustive - Health check /api/v1/health (Supabase + Redis + WAHA pings) - docker-compose for local WAHA dev - vercel.ts with 7 cron schedules - CLAUDE.md with project conventions and anti-patterns SUPABASE SCHEMA DEPLOYED (sa-east-1, project rrydmwnporysaiysiztn): - 19 tables with RLS enabled - platform_base: organizations, user_organizations, platform_admins, api_tokens, api_audit_log (append-only), user_recovery_codes, idempotency_keys - event_log + emit_event/fn_log_event helpers (bus interno; trigger NEVER calls HTTP) - customer_360: contacts (CPF encrypted via pgcrypto), crm_pipelines, crm_stages, crm_leads, crm_lead_activities, crm_lead_links, merge_queue - whatsapp: channel_sessions, channel_session_warmup, conversations, messages, webhook_events_log - Triggers: auto won/lost via stage flags, denorm last_activity_at, emit lead/message events, seed default pipeline on org create, validate lost_reason - RLS helpers: fn_user_org_ids, fn_is_platform_admin, fn_user_role_in_org, fn_role_at_least - 4 migrations applied: 0001 platform_base, 0002 event_log+compat, 0003 customer_360, 0004 whatsapp_waha 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |