Nesta página
  1. O que ela pede vs. o que ela precisa
  2. As perguntas antes de escrever código
  3. As regras viram código
  4. 1. Sem horário duplicado: a regra mora no banco
  5. 2. Erro não pode virar sucesso
  6. 3. Cada um vê só o que é seu
  7. 4. Lembrete que reduz falta
  8. Checklist antes de entregar

Fazer o site é a parte fácil: como eu montaria a agenda de uma dentista do jeito certo

6 mincodigosupabase

Recebi um comentário dizendo que criar site é tão básico que é difícil acreditar que alguém ganhe dinheiro com isso. Ele tem razão sobre a primeira parte: a tela é a parte fácil. O valor está em entender a regra do negócio e garantir que o sistema continue funcionando quando alguém usa de verdade.

Pra mostrar onde está esse trabalho, vou montar uma agenda online de uma dentista, da primeira conversa até o banco de dados. A dentista é um exemplo para ilustrar, não um cliente real. A stack é a que eu uso no dia a dia: Next.js, Supabase e n8n.

O que ela pede vs. o que ela precisa

Ninguém contrata páginas. Por trás de cada pedido existe um problema do dia a dia e uma regra que o sistema precisa cumprir.

Ela dizO problema realA regra por trás
Quero um site com agendamentoA recepção passa o dia respondendo WhatsAppO paciente marca sozinho, sem dois no mesmo horário
Quero lembrar os pacientesPaciente que falta deixa a cadeira vaziaLembrete automático com confirmação
Só eu e a recepção vemos a agendaSão dados de saúde de outras pessoasCada pessoa vê só o que é dela

Se eu fizer só a tela, entrego a primeira coluna. Ela está pagando pela terceira.

As perguntas antes de escrever código

Antes de abrir o editor, eu faço perguntas que fazem as regras escondidas aparecerem. Cada resposta vira uma regra, e cada regra vira código.

PerguntaA regra que ela revela
E se dois pacientes tentarem o mesmo horário ao mesmo tempo?O banco recusa o segundo, não a tela
Cada consulta dura quanto? Varia por procedimento?O horário é um intervalo, não um ponto
Quando o paciente cancela, o horário volta a ficar livre?Cancelado não conta como conflito
Se der erro na hora de salvar, o que o paciente vê?Erro nunca aparece como sucesso
Quem pode ver a agenda? E o paciente, vê o quê?Autorização por usuário em toda leitura
Com quanto tempo vai o lembrete? E se ele não confirmar?Rotina diária + destaque pra recepção

Nenhuma dessas perguntas aparece quando alguém pede pra IA "um app de agendamento". Por isso o app fica pronto em 10 minutos e quebra no primeiro dia de uso.

As regras viram código

1. Sem horário duplicado: a regra mora no banco

Validar na tela não segura dois pedidos que chegam no mesmo instante. Quem precisa recusar o segundo é o banco.

agendamentos.sql · sql
create extension if not exists btree_gist;

create table agendamentos (
  id uuid primary key default gen_random_uuid(),
  profissional_id uuid not null references profissionais(id),
  paciente_id uuid not null references auth.users(id),
  horario tstzrange not null,
  status text not null default 'confirmado',
  constraint sem_conflito exclude using gist (
    profissional_id with =,
    horario with &&
  ) where (status <> 'cancelado')
);

A restrição diz: para o mesmo profissional, dois horários não podem se sobrepor, a não ser que um esteja cancelado. O intervalo [início, fim) deixa consultas encostadas (9h–10h e 10h–11h) passarem normalmente.

2. Erro não pode virar sucesso

Esse é o erro mais comum que vejo em código gerado por IA:

engole-erro.ts · ts
try {
  await supabase.from('agendamentos').insert(novo);
} catch {}
return { sucesso: true };

O supabase-js não lança exceção quando o banco recusa: ele devolve um error. O try/catch nunca dispara, a tela mostra "sucesso" e nada foi salvo. Na rota do back-end, o certo é olhar o error:

app/api/agendamentos/route.ts · ts
// app/api/agendamentos/route.ts
import { createClient } from '@/lib/supabase/server';

export async function POST(req: Request) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return Response.json({ erro: 'Faça login para agendar.' }, { status: 401 });

  const { profissionalId, inicio, fim } = await req.json();
  const { error } = await supabase.from('agendamentos').insert({
    profissional_id: profissionalId,
    paciente_id: user.id,
    horario: `[${inicio},${fim})`,
  });

  if (error?.code === '23P01') {
    return Response.json({ erro: 'Esse horário acabou de ser reservado. Escolha outro.' }, { status: 409 });
  }
  if (error) {
    console.error(error);
    return Response.json({ erro: 'Não foi possível salvar. Tente de novo.' }, { status: 500 });
  }
  return Response.json({ ok: true }, { status: 201 });
}

23P01 é o código do Postgres para a restrição sem_conflito. No front, o formulário só é limpo quando a resposta é ok. Se der erro, o paciente vê a mensagem e não perde o que digitou.

3. Cada um vê só o que é seu

Eu prefiro que o front nunca fale direto com o banco: ele chama a minha API, e só ela fala com o Supabase. Mas a API não usa a chave service_role, que ignora todas as regras. Ela usa a mesma chave anon que o front usaria, repassando o token do usuário logado. Assim nenhuma chave vai para o navegador, e o banco continua aplicando as regras para cada pessoa.

lib/supabase/server.ts · ts
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_ANON_KEY!, // sem NEXT_PUBLIC_: não vai pro navegador
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (lista) =>
          lista.forEach(({ name, value, options }) => cookieStore.set(name, value, options)),
      },
    },
  );
}

O createServerClient lê a sessão dos cookies e manda o token junto em cada consulta. Com isso, as políticas de RLS sabem quem está pedindo:

rls.sql · sql
alter table agendamentos enable row level security;

-- o paciente vê e cria só os próprios agendamentos
create policy "paciente: os próprios" on agendamentos
  for select using (paciente_id = auth.uid());
create policy "paciente: agenda pra si" on agendamentos
  for insert with check (paciente_id = auth.uid());

-- a equipe vê a agenda do profissional em que trabalha
create policy "equipe: agenda do profissional" on agendamentos
  for select using (
    profissional_id in (select profissional_id from equipe where user_id = auth.uid())
  );

Se uma rota esquecer de checar algo, o RLS ainda segura. Para a chave não aparecer no navegador, o login também passa pelo servidor (server actions), não pelo supabase-js no cliente.

4. Lembrete que reduz falta

O lembrete é uma rotina no n8n que roda todo dia: busca as consultas do dia seguinte, manda a mensagem com um botão de confirmar e marca quem confirmou. Quem não confirma até o fim do dia aparece destacado pra recepção ligar.

Esse é o único lugar onde a service_role entra, porque a rotina não tem um usuário logado e precisa ver a agenda inteira. Ela fica guardada só na credencial do n8n, nunca em rota ou no front.

Checklist antes de entregar

  • Conflito de horário bloqueado no banco, não só na tela
  • Todo error do Supabase tratado; nenhum erro retorna sucesso
  • Formulário não perde os dados quando falha
  • Rotas usam a chave anon + token do usuário (RLS aplicado)
  • RLS ligado em todas as tabelas
  • service_role só em rotinas internas (lembretes), nunca em rota ou no front
  • Lembrete com confirmação e destaque pra quem não confirmou
  • Backup configurado

Fazer a tela continua sendo a parte fácil, e a IA faz isso em minutos. O que a dentista paga é esse checklist funcionando numa segunda-feira com o consultório cheio. Se você usa IA pra programar, use pra acelerar essa lista, não pra pular ela.