Fazer o site é a parte fácil: como eu montaria a agenda de uma dentista do jeito certo
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 diz | O problema real | A regra por trás |
|---|---|---|
| Quero um site com agendamento | A recepção passa o dia respondendo WhatsApp | O paciente marca sozinho, sem dois no mesmo horário |
| Quero lembrar os pacientes | Paciente que falta deixa a cadeira vazia | Lembrete automático com confirmação |
| Só eu e a recepção vemos a agenda | São dados de saúde de outras pessoas | Cada 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.
| Pergunta | A 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.
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:
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
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
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:
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
errordo 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_rolesó 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.
