Neuroredes
Plataforma

Organizações

CRUD das empresas-cliente em mydb.companies. É também a âncora do modelo de papéis: ADMIN tem company_id nulo e enxerga tudo; SURVEYOR e VIEWER carregam um company_id que define o escopo de pesquisas e administradores que podem ver.

Capítulo 01

Onde mora o módulo

A tela vive em app/(main)/companies/, sob o route group com header global. A entrada é autenticada (middleware exige sessão Supabase) e o Server Component faz o gate de papel — VIEWER é redirecionado direto para /surveys. ADMIN e SURVEYOR chegam à mesma página, mas com payloads distintos (ver Cap. 05).

Arquivo Responsabilidade
app/(main)/companies/page.tsx Server Component. Lê cookie user_profile, redireciona VIEWER, chama getCompaniesRpc(profile) envolvido em cache() do React e injeta o resultado em CompaniesClient.
app/(main)/companies/actions.ts Server Actions createCompanyAction, updateCompanyAction, deleteCompanyAction. Cada uma re-roda o Zod, chama a RPC correspondente e dispara revalidatePath('/companies').
components/Companies/CompaniesClient/CompaniesClient.tsx Layout em dois painéis: lista de cards à esquerda (busca + filtro de data + paginação), modal lateral de edição/criação à direita. Renderiza ConfirmDialog para exclusão.
components/Companies/CompaniesCard/CompaniesCard.tsx Card individual. Mostra trade_name, tax_id, corporate_name, founding_date. Botões editar (todos os papéis com acesso) e excluir (apenas ADMIN).
components/Companies/CreateCompanyForm/CreateCompanyForm.tsx Formulário de criação. Aplica máscara via formatCNPJ e formatPhone em tempo de digitação; valida com Zod no submit.
components/Companies/UpdateCompanyForm/UpdateCompanyForm.tsx Formulário de edição. Mesma validação e máscara; inclui normalizeToYYYYMMDD para reformatar founding_date vindo do banco.
rpc/companies/{get-companies,get-company,create-company,update-company,delete-company,index}.ts Camada RPC. Cada arquivo exporta uma função fina que chama supabase.rpc(...) e mapeia o retorno para o tipo Company.
services/companies.service.ts Service legado que invoca a edge companies-crud. Ainda em uso pelo dropdown de empresas em surveys/create e surveys/[id]/edit (ver Cap. 06).
supabase/functions/companies-crud/index.ts Edge Function (deployada; desde 2026-06-10 vive no repo, verify_jwt: true — derivava identidade do profile do corpo, hoje deriva do JWT via _shared/auth.ts). Internamente despacha para as mesmas RPCs do banco.
lib/zod/company.validation.ts companyValidationSchema e o tipo derivado CompanyFormData. Ver Cap. 04.
types/company.type.ts Company, CreateCompanyPayload, UpdateCompanyPayload, PaginatedCompaniesResponse.
mydb.companies Tabela física. 12 colunas, PK id bigint serial, dois UNIQUE indexes legados em email e tax_id. Ver Cap. 02.
Capítulo 02

Modelo de dados

A tabela mydb.companies tem 12 colunas. Os campos marcados NOT NULL são exigidos no INSERT mas nenhum tem default no banco — a obrigatoriedade aparece na Zod e no formulário antes de chegar ao Postgres.

id
bigint, PK. Default nextval('mydb.companies_id_seq'). É o company_id referenciado por outras tabelas (sem FK declarada — ver Cap. 07).
trade_name
varchar(255), NOT NULL. Nome fantasia. Exibido no card como título.
corporate_name
varchar(255), NOT NULL. Razão social.
tax_id
varchar(255), nullable. CNPJ no formato XX.XXX.XXX/XXXX-XX. Coberto por UNIQUE index idx_30652_companies_tax_id_unique.
email
varchar(255), nullable. Coberto por UNIQUE index idx_30652_companies_email_unique.
administrator
varchar(255), NOT NULL. Nome do responsável pelo cadastro. Texto livre — não é referência a uma tabela de usuários.
founding_date
timestamptz, nullable. Data de fundação da empresa.
phone, address, website
Todos opcionais (varchar(255) / text / varchar(255)). website recebe auto-prefixo https:// na Zod se faltar protocolo.
created_at, updated_at
timestamptz, NOT NULL, default CURRENT_TIMESTAMP. A coluna updated_at é atualizada pela própria RPC update_company com now(); não há trigger.
Nota

Os UNIQUE indexes não aparecem em pg_constraint porque são índices criados pela migração legada Laravel (prefixo idx_30652_*), não declarados como constraints nomeadas. Os dois unique constraints em prática vigoram: colisão devolve 23505 do Postgres. A edge companies-crud converte para HTTP 409; a Server Action deixa o error.message passar cru no toast.

População atual

15 linhas em mydb.companies (consulta MCP em 2026-05-22). Distribuição em mydb.surveys: 8 empresas distintas respondem por todas as pesquisas — 7 das 15 não têm nenhuma pesquisa associada. Entre as registradas convivem dados reais e teste (“Empresa_50”, “Teste Criar Empresa22333”, “Magic Link Corporation Teste”, “Pet Love (Teste)”).

Capítulo 03

Composição da tela

CompaniesClient monta um layout em dois painéis. O esquerdo é a lista de organizações; o direito é um modal contextual que aparece quando o usuário clica em um card (modo editing) ou no botão de adicionar (modo creating). O modo idle esconde o painel direito.

Elemento O que faz
SearchFilterBar Campo de busca livre + dois inputs de data. A busca filtra corporate_name, trade_name e tax_id com toLowerCase().includes(query).
Lista de cards filtered.map(...) sob paginação de 10 itens via usePagination. Empty state com EmptyState quando não há resultado.
CompaniesCard Mostra título (trade_name), CNPJ (tax_id), meta linha com corporate_name e “Fundada em dd/mm/aaaa”. Botão lápis (editar) e botão lixeira (excluir, apenas ADMIN).
Pagination Numerada, label “organizações”.
Botão “Adicionar nova organização” Renderizado apenas quando role === 'ADMIN'. Abre CreateCompanyForm.
CreateCompanyForm / UpdateCompanyForm Formulários idênticos em campos. tax_id e phone recebem máscara em onChange; data de fundação é input[type=date].
ConfirmDialog Pergunta antes da exclusão. Mensagem em CompaniesClient.tsx afirma que a ação “irá excluir TODOS os ADMINISTRADORES DE PESQUISA e PESQUISAS relacionadas à organização” — afirmação falsa, ver Cap. 07.
Atenção

Os dois inputs de data são rotulados “Fundada a partir de” e “Fundada até”, mas o predicado em filtered compara contra c.created_at, não contra founding_date. O filtro entrega resultados por data de cadastro, não pela fundação real da empresa.

Capítulo 04

Validação (Zod)

companyValidationSchema em lib/zod/company.validation.ts é a única camada de validação semântica. A edge companies-crud só checa “campos obrigatórios” de forma trivial; as RPCs do banco não validam nada além do tipo da coluna. Vale tanto na entrada quanto na edição.

lib/zod/company.validation.ts typescript
const cnpjRegex = /^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$/;

export const companyValidationSchema = z.object({
  trade_name: z.string().min(1, 'Nome fantasia é obrigatório.'),
  corporate_name: z.string().min(1, 'Razão social é obrigatória.'),
  administrator: z.string().min(1, 'Administrador é obrigatório.'),
  email: z.email('E-mail inválido.').optional().or(z.literal('')),
  tax_id: z.string().optional().or(z.literal(''))
    .refine((val) => !val || cnpjRegex.test(val),
            'CNPJ deve estar no formato XX.XXX.XXX/XXXX-XX'),
  // website: auto-prefixa https:// e valida com new URL()
});

Três campos são obrigatórios: trade_name, corporate_name, administrator. Os demais (email, tax_id, phone, address, website, founding_date) aceitam string vazia ou undefined. O website tem comportamento peculiar: a Zod aplica transform que prefixa https:// se faltar protocolo e em seguida valida com new URL().

Atenção

A Zod não valida unicidade de email e tax_id. O usuário só descobre a colisão quando a RPC chama o INSERT e o Postgres devolve 23505. A Server Action surfacia o error.message cru no toast, então a mensagem que o usuário vê inclui o nome do índice (idx_30652_companies_tax_id_unique) — sem tradução.

Capítulo 05

Fluxos

Listar

Server Component em page.tsx lê o cookie user_profile. VIEWER é redirecionado imediatamente para /surveys. ADMIN e SURVEYOR seguem para getCompaniesRpc(profile), que despacha por papel:

  • ADMINsupabase.rpc('get_companies_paginated', { p_schema: 'mydb', p_search: null, p_page: 1, p_per_page: 100 }). Devolve até 100 organizações ordenadas por created_at desc.
  • SURVEYOR com profile.company_id definido → supabase.rpc('get_company_by_id', { p_schema: 'mydb', p_id: profile.company_id }). Devolve uma única linha.
  • SURVEYOR sem company_id, ou qualquer outro caso → array vazio.

O fetch é envolvido em cache() do React para deduplicar chamadas concomitantes dentro do mesmo request. mapRow normaliza null para string vazia em todos os campos opcionais — o card sempre recebe strings, mesmo quando o banco devolve NULL.

Criar

CreateCompanyForm valida com Zod no submit. Se passar, chama createCompanyAction(formData). A Server Action re-valida com Zod (segundo round, blindagem contra payload adulterado) e chama createCompanyRpc:

rpc/companies/create-company.ts typescript
const { data, error } = await supabase.rpc('create_company', {
  p_schema: 'mydb',
  p_trade_name: payload.trade_name,
  p_corporate_name: payload.corporate_name,
  p_tax_id: payload.tax_id || null,
  p_founding_date: payload.founding_date || null,
  p_administrator: payload.administrator,
  p_address: payload.address || null,
  p_phone: payload.phone || null,
  p_email: payload.email || null,
  p_website: payload.website || null,
});

A RPC public.create_company é SECURITY DEFINER, executa um INSERT dinâmico montado com format('%I') (interpolação de identificador, à prova de injection no p_schema), e devolve a linha como jsonb. Sucesso → revalidatePath('/companies') + toast.

Editar

Idêntico ao criar, mas a RPC update_company usa coalesce por campo:

public.update_company (resumo) sql
update %I.companies c set
  trade_name     = coalesce($2, c.trade_name),
  corporate_name = coalesce($3, c.corporate_name),
  tax_id         = coalesce($4, c.tax_id),
  -- ...demais campos
  updated_at     = now()
where c.id = $1
returning to_jsonb(c)
Atenção

O coalesce mantém o valor existente quando o argumento é null. Mas o adapter em rpc/companies/update-company.ts converte '' para null via || null antes de chamar a RPC. Resultado: limpar um campo opcional no formulário não apaga o valor no banco — mantém o que já estava lá. Apagar mesmo só com UPDATE direto no banco ou alterando o adapter.

Excluir

Card → ícone de lixeira (apenas ADMIN) → handleDeleteCompany abre ConfirmDialog. Se o usuário confirmar, deleteCompanyAction(companyId) chama deleteCompanyRpcdelete_companyDELETE FROM mydb.companies WHERE id = $1. Sucesso → remoção otimista da lista local + toast.

app/(main)/companies/actions.ts typescript
export async function deleteCompanyAction(companyId: number) {
  try {
    const success = await deleteCompanyRpc(companyId);
    if (success) {
      revalidatePath('/companies');
      return { success: true, error: null };
    }
    return { success: false, error: 'Erro ao excluir organização.' };
  } catch (error) {
    return { success: false, error: error?.message ?? 'Erro interno.' };
  }
}
Capítulo 06

Edge companies-crud × RPCs diretas

Organizações foi um dos módulos convertidos no padrão RPC direto. A UI de companies usa exclusivamente as funções em rpc/companies/; nenhuma operação CRUD da própria tela passa pela edge. Mesmo assim, a edge continua deployada (verify_jwt: true desde 2026-06-10) e tem caller real em outro módulo.

Operação Caminho efetivo Caller
Listar RPC get_companies_paginated ou get_company_by_id app/(main)/companies/page.tsx via getCompaniesRpc. Também app/(main)/surveyors/page.tsx e app/(main)/viewers/page.tsx reaproveitam a mesma RPC para popular dropdowns.
Criar RPC create_company Apenas a Server Action createCompanyAction.
Editar RPC update_company Apenas updateCompanyAction.
Excluir RPC delete_company Apenas deleteCompanyAction.
Listar (edge) Edge companies-crud → RPC get_companies_paginated/get_company_by_id services/companies.service.ts:getCompanies, importado por app/(main)/surveys/create/page.tsx e app/(main)/surveys/[id]/edit/page.tsx para o dropdown de empresas no stepper de pesquisa.

A edge resolve o papel reconsultando o banco via get_user_by_id (com p_table = 'users' ou 'surveyors') e despacha para a mesma RPC do banco que a UI usaria. Ou seja: mesmo no caminho que ainda passa pela edge, o trabalho efetivo termina na RPC. A edge resta como camada intermediária — útil para isolar a checagem de papel do client, mas redundante com o gate que o Server Component já faz via cookie.

Nota

As funções createCompany, updateCompany e deleteCompany em services/companies.service.ts estão definidas mas nenhum lugar do projeto as importa. Código vestigial pronto para remoção no próximo passe.

Capítulo 07

Cascata de exclusão e dependências

Em outras tabelas do mydb a exclusão dispara cascata (ver surveys.html Cap. 05 para o modelo da pesquisa). Em companies isso não acontece. A consulta information_schema.referential_constraints com ccu.table = 'companies' devolve lista vazia (consulta MCP em 2026-05-22): nenhuma foreign key declarada aponta para mydb.companies.

Os bigints/numerics que atuam como company_id em outras tabelas — mydb.surveys.company_id, mydb.surveyors.company_id, mydb.users.company_id — são colunas livres. O Postgres não impõe integridade referencial e delete_company é um DELETE FROM companies cru. Excluir empresa deixa surveys, surveyors e users órfãos com company_id apontando para uma linha que não existe mais.

Atenção

O ConfirmDialog em components/Companies/CompaniesClient/CompaniesClient.tsx mostra a mensagem: “Esta ação irá excluir TODOS os ADMINISTRADORES DE PESQUISA e PESQUISAS relacionadas à organização.” A afirmação é falsa. A exclusão remove apenas a linha de mydb.companies. Surveys da empresa permanecem listáveis (com company_id dangling), SURVEYORs permanecem ativos. Quem acreditar na mensagem terá um modelo mental errado da operação.

Reordem caso a integridade vire requisito: ou declarar FKs explícitas em surveys/surveyors/users com ON DELETE CASCADE (ou RESTRICT), ou reescrever delete_company como cascata aplicativa em múltiplas etapas — análogo ao que delete-survey já faz para pesquisas (ver surveys.html Cap. 05 — Excluir).

Capítulo 08

Permissões, RLS e RPCs abertas

A matriz de papéis para Organizações é compacta: só ADMIN escreve, SURVEYOR lê a própria empresa, VIEWER nem chega à rota. Tudo derivado da gate em page.tsx e da condicional role === 'ADMIN' nos botões da UI.

Papel Acesso à rota /companies O que enxerga e pode fazer
ADMIN Sim Lista todas as 15 organizações via get_companies_paginated. Cria, edita e exclui. Botão “Adicionar nova organização” e lixeira por card visíveis.
SURVEYOR Sim Lista apenas a própria empresa via get_company_by_id(profile.company_id). Pode editar (clique no card → UpdateCompanyForm) mas o botão de adicionar e a lixeira ficam ocultos.
VIEWER Não — redirect('/surveys')
Anônimo Bloqueado pelo middleware (sem sessão → /login).

A definição de papel em si — derivada da tabela-perfil onde o supabase_auth_id aparece, e não de uma coluna explícita — vive em auth.html Cap. 02. A matriz completa do produto está em auth.html Cap. 06.

RLS da tabela

mydb.companies tem relrowsecurity = true. Duas policies vigoram:

  • Authenticated users can view companies — SELECT para {public}, qual (select auth.role()) = 'authenticated'. Qualquer login lê a tabela inteira via PostgREST com schema('mydb'). Já catalogado na lista “RLS habilitada não escopada” do backend.html Cap. 02.
  • Service role can manage companies — ALL para service_role (edges, lambdas).

RPCs com EXECUTE para anon/authenticated

Atenção

As cinco RPCs (create_company, update_company, delete_company, get_companies_paginated, get_company_by_id) são SECURITY DEFINER (owner postgres) e têm EXECUTE concedido a anon e authenticated (consulta MCP em 2026-05-22). Nenhuma re-valida papel: a única gate de escrita é a UI esconder os botões. Qualquer cliente com a chave pública anon consegue chamar POST /rest/v1/rpc/delete_company com { "p_schema": "mydb", "p_id": N } e remover a organização. create_company e update_company são vulneráveis na mesma forma. Mitigar com REVOKE EXECUTE ... FROM anon, authenticated e expor só via edge autenticada, ou adicionar checagem de papel dentro da RPC.

O parâmetro p_schema é interpolado via format('%I'), então não há vetor de SQL injection — mas ele permite que o caller escolha qualquer schema com tabela companies compatível. No projeto atual, só mydb serve.

Capítulo 09

Integração com outros módulos

  • Pesquisas: cada pesquisa carrega company_id. ADMIN escolhe a empresa no stepper; SURVEYOR só consegue criar para a própria empresa. O dropdown do stepper é populado por getCompanies da edge, único caller real do companies-crud hoje (ver Cap. 06).
  • Administradores: cada SURVEYOR tem company_id fixo. A página de criação também usa getCompaniesRpc para o dropdown de empresas.
  • Autenticação & Papéis: ADMIN tem profile.company_id = null — ações que precisam de company_id de uma pesquisa específica (excluir, encerrar) usam o survey.empresa_id do recurso-alvo. O modelo está em auth.html Cap. 06.
  • Backend (Supabase): as RPCs companies.* são parte do catálogo de funções no schema public (com SET search_path = public, mydb), padrão descrito em backend.html Cap. 05. A edge companies-crud entra no catálogo de edges em backend.html Cap. 03, com verify_jwt: true desde o endurecimento de 2026-06-10 (antes era false).
Capítulo 10

Pegadinhas

  1. Cascade falso na exclusão. O ConfirmDialog afirma que excluir uma organização apaga em cascata pesquisas e administradores. Não apaga nada além da própria linha. Sem FK declarada, surveys e surveyors órfãos ficam apontando para um company_id inexistente (ver Cap. 07).
  2. RPCs abertas a anon/authenticated. As cinco RPCs do módulo aceitam chamada direta via PostgREST com a chave pública. Nenhuma re-valida papel. Qualquer cliente com a chave anon pode criar/editar/excluir empresas arbitrariamente. A UI esconder os botões não é gate — só camuflagem (ver Cap. 08).
  3. Filtro de data com label errado. “Fundada a partir de” e “Fundada até” filtram por created_at, não por founding_date. Bug de etiqueta em CompaniesClient.tsx.
  4. Limpar campo opcional não apaga. A combinação de || null no adapter rpc/companies/update-company.ts com o coalesce dentro da RPC mantém o valor antigo quando o usuário apaga o conteúdo. Para apagar mesmo, falta um sentinel distinto de “não enviado”.
  5. UNIQUE como índice, não como constraint. email e tax_id têm UNIQUE garantida por índices Laravel legados (idx_30652_*), invisíveis em pg_constraint. A Zod não valida unicidade no client; colisão volta como 23505 cru no toast.
  6. Edge dormente mas viva. companies-crud continua deployada (verify_jwt: true desde 2026-06-10) e ainda atende o dropdown de empresas em surveys/create e surveys/[id]/edit. Mexer na edge afeta esses dois fluxos; mexer nas RPCs afeta o resto. Não é “a migração terminou e a edge pode sumir”.
  7. Dados de teste em produção. 7 das 15 empresas não têm pesquisa associada e várias têm nome claramente de teste (“Empresa_50”, “Teste Criar Empresa22333”, “Magic Link Corporation Teste”, “Pet Love (Teste)”). Sem flag is_test nem distinção entre ambientes dentro do schema.
  8. Strings vazias no lugar de null no client. mapRow em rpc/companies/get-companies.ts converte null para '' em todos os campos string. O card e os formulários sempre recebem string, o que simplifica o JSX mas perde a distinção entre “não informado” e “informado como vazio”.