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.
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. |
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. Defaultnextval('mydb.companies_id_seq'). É ocompany_idreferenciado 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 formatoXX.XXX.XXX/XXXX-XX. Coberto por UNIQUE indexidx_30652_companies_tax_id_unique.varchar(255), nullable. Coberto por UNIQUE indexidx_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)).websiterecebe auto-prefixohttps://na Zod se faltar protocolo. - created_at, updated_at
timestamptz, NOT NULL, defaultCURRENT_TIMESTAMP. A colunaupdated_até atualizada pela própria RPCupdate_companycomnow(); não há trigger.
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)”).
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. |
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.
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.
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().
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.
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:
- ADMIN →
supabase.rpc('get_companies_paginated', { p_schema: 'mydb', p_search: null, p_page: 1, p_per_page: 100 }). Devolve até 100 organizações ordenadas porcreated_at desc. - SURVEYOR com
profile.company_iddefinido →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:
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:
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)
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
deleteCompanyRpc → delete_company →
DELETE FROM mydb.companies WHERE id = $1. Sucesso →
remoção otimista da lista local + toast.
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.' };
}
}
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.
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.
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.
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).
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 comschema('mydb'). Já catalogado na lista “RLS habilitada não escopada” do backend.html Cap. 02. -
Service role can manage companies— ALL paraservice_role(edges, lambdas).
RPCs com EXECUTE para anon/authenticated
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.
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 porgetCompaniesda edge, único caller real docompanies-crudhoje (ver Cap. 06). -
Administradores: cada
SURVEYOR tem
company_idfixo. A página de criação também usagetCompaniesRpcpara o dropdown de empresas. -
Autenticação & Papéis:
ADMIN tem
profile.company_id = null— ações que precisam decompany_idde uma pesquisa específica (excluir, encerrar) usam osurvey.empresa_iddo 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 schemapublic(comSET search_path = public, mydb), padrão descrito em backend.html Cap. 05. A edgecompanies-crudentra no catálogo de edges em backend.html Cap. 03, comverify_jwt: truedesde o endurecimento de 2026-06-10 (antes erafalse).
Pegadinhas
-
Cascade falso na exclusão. O
ConfirmDialogafirma 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 umcompany_idinexistente (ver Cap. 07). -
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
anonpode criar/editar/excluir empresas arbitrariamente. A UI esconder os botões não é gate — só camuflagem (ver Cap. 08). -
Filtro de data com label errado. “Fundada a
partir de” e “Fundada até” filtram por
created_at, não porfounding_date. Bug de etiqueta emCompaniesClient.tsx. -
Limpar campo opcional não apaga. A combinação de
|| nullno adapterrpc/companies/update-company.tscom ocoalescedentro 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”. -
UNIQUE como índice, não como constraint.
emailetax_idtêm UNIQUE garantida por índices Laravel legados (idx_30652_*), invisíveis empg_constraint. A Zod não valida unicidade no client; colisão volta como23505cru no toast. -
Edge dormente mas viva.
companies-crudcontinua deployada (verify_jwt: truedesde 2026-06-10) e ainda atende o dropdown de empresas emsurveys/createesurveys/[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”. -
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_testnem distinção entre ambientes dentro do schema. -
Strings vazias no lugar de null no client.
mapRowemrpc/companies/get-companies.tsconvertenullpara''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”.