Testado de verdade. Todo o SQL e todo arquivo src/lib desta página rodaram contra o SuperDB em produção em 21/10/2026, num projeto de teste que foi apagado depois: 28 verificações, 28 passaram. Os trechos de tela (formulário de login e o agendador do GitHub Actions) seguem a documentação e não fizeram parte do teste. As saídas estão no repositório do SuperDB, em docs/marketing/aprenda/provas/agenda.txt.
O problema do negócio
Salão, barbearia, estúdio, clínica de estética, consultório: a agenda vive no caderno ou no WhatsApp da recepção. Cliente esquece o horário, dois clientes caem no mesmo encaixe, e a dona só descobre quando os dois chegam. O app resolve três coisas:
- o cliente marca sozinho, vendo só os horários que estão livres;
- o banco recusa horário sobreposto, mesmo com dois pedidos no mesmo segundo;
- o cliente recebe um lembrete na véspera, e a equipe vê e confirma a agenda inteira.
O modelo de dados
| Tabela | O que guarda | Quem lê | Quem altera |
|---|---|---|---|
servicos | nome, duração em minutos, preço | qualquer visitante (os ativos) | só a dona |
profissionais | quem atende | qualquer visitante (os ativos) | só a dona |
equipe | logins da equipe e o papel (dono, recepcao, profissional) | cada um a própria linha | você, pelo SQL Editor ou pelo servidor |
agendamentos | cliente, serviço, profissional, início, fim, status | o cliente os dele; a equipe todos | o cliente marca e cancela; a equipe confirma |
Três regras ficam no banco, e não no app, porque o app pode ser trocado, copiado ou chamado direto pela API:
- O fim vem do serviço. O app manda só o início; o gatilho
agendamento_conferesoma a duração. - Horário sobreposto é recusado com o erro
horario_ocupado, sob uma trava por profissional. - O cliente só mexe no status, e só para cancelar. A coluna
cliente_idnem aceita escrita: ela vem do login.
Passo 1: crie a conta e o projeto
Crie a conta e um projeto no painel. Guarde o slug do projeto e a anon key (em API Keys). A service_role fica só no seu servidor.
Passo 2: rode o SQL das tabelas
No painel, abra o projeto, vá em SQL Editor, cole tudo e rode. Pode rodar de novo sem medo. A tabela já nasce com RLS ligada e fechada no SuperDB; o SQL liga de novo de propósito, para valer em qualquer Postgres, e abre só o que cada papel precisa.
-- Agenda de salão ou clínica: serviços, profissionais, equipe e agendamentos.
-- Pode rodar de novo sem medo: tudo é "if not exists" ou "or replace".
-- Quem trabalha no negócio. Quem entra aqui é decisão sua (painel ou servidor).
create table if not exists equipe (
user_id uuid primary key,
nome text not null,
papel text not null default 'profissional'
check (papel in ('dono', 'recepcao', 'profissional'))
);
-- O que o negócio oferece, com duração e preço.
create table if not exists servicos (
id bigint generated always as identity primary key,
nome text not null,
duracao_min int not null check (duracao_min between 5 and 480),
preco numeric(10,2) not null check (preco >= 0),
ativo boolean not null default true
);
-- Quem atende. O user_id liga o profissional a um login, se ele usar o app.
create table if not exists profissionais (
id bigint generated always as identity primary key,
nome text not null,
user_id uuid unique,
ativo boolean not null default true
);
create table if not exists agendamentos (
id uuid primary key default gen_random_uuid(),
cliente_id uuid not null default auth.uid(),
cliente_nome text not null check (length(cliente_nome) between 1 and 120),
servico_id bigint not null references servicos(id),
profissional_id bigint not null references profissionais(id),
inicio timestamptz not null,
fim timestamptz not null,
status text not null default 'marcado'
check (status in ('marcado', 'confirmado', 'cancelado', 'concluido')),
lembrete_enviado_em timestamptz,
criado_em timestamptz not null default now()
);
create index if not exists agendamentos_por_profissional
on agendamentos (profissional_id, inicio);
alter table equipe enable row level security;
alter table servicos enable row level security;
alter table profissionais enable row level security;
alter table agendamentos enable row level security;
-- "Quem pergunta é da equipe?" SECURITY DEFINER: lê a equipe com a permissão
-- do dono do projeto, sem abrir a tabela para ninguém.
create or replace function e_da_equipe(p_papel text default null) returns boolean
language sql stable security definer set search_path from current as $$
select exists (
select 1 from equipe
where user_id = auth.uid() and (p_papel is null or papel = p_papel)
)
$$;
-- O fim vem da duração do serviço (o app não decide), e dois horários do
-- mesmo profissional não se sobrepõem. SECURITY DEFINER porque o cliente não
-- enxerga os horários dos outros, e a conferência precisa enxergar.
create or replace function agendamento_confere() returns trigger
language plpgsql security definer set search_path from current as $$
declare
duracao int;
begin
-- Cancelar não confere nada: libera o horário.
if tg_op = 'UPDATE' and new.status = 'cancelado' then
return new;
end if;
select s.duracao_min into duracao from servicos s where s.id = new.servico_id and s.ativo;
if duracao is null then
raise exception 'servico_indisponivel';
end if;
new.fim := new.inicio + make_interval(mins => duracao);
if tg_op = 'INSERT' and new.inicio < now() then
raise exception 'horario_no_passado';
end if;
-- Uma marcação por vez para o mesmo profissional: sem isso, dois pedidos
-- no mesmo segundo passariam juntos pela conferência.
perform pg_advisory_xact_lock(hashtext('agenda'), new.profissional_id::int);
if new.status <> 'cancelado' and exists (
select 1 from agendamentos a
where a.profissional_id = new.profissional_id
and a.id <> new.id
and a.status <> 'cancelado'
and tstzrange(a.inicio, a.fim) && tstzrange(new.inicio, new.fim)
) then
raise exception 'horario_ocupado';
end if;
return new;
end $$;
drop trigger if exists agendamento_confere on agendamentos;
create trigger agendamento_confere
before insert or update of inicio, servico_id, profissional_id, status on agendamentos
for each row execute function agendamento_confere();
-- Catálogo: qualquer visitante vê o que está ativo; só o dono altera.
drop policy if exists servicos_ler on servicos;
create policy servicos_ler on servicos for select to anon, authenticated
using (ativo or e_da_equipe());
drop policy if exists servicos_dono on servicos;
create policy servicos_dono on servicos for all to authenticated
using (e_da_equipe('dono')) with check (e_da_equipe('dono'));
drop policy if exists profissionais_ler on profissionais;
create policy profissionais_ler on profissionais for select to anon, authenticated
using (ativo or e_da_equipe());
drop policy if exists profissionais_dono on profissionais;
create policy profissionais_dono on profissionais for all to authenticated
using (e_da_equipe('dono')) with check (e_da_equipe('dono'));
-- Equipe: cada um vê a própria linha (o app usa para saber se mostra a agenda).
drop policy if exists equipe_ver_a_propria on equipe;
create policy equipe_ver_a_propria on equipe for select to authenticated
using (user_id = auth.uid());
-- Agendamentos: o cliente vê e marca os dele; a equipe vê e altera todos.
drop policy if exists agendamentos_ler on agendamentos;
create policy agendamentos_ler on agendamentos for select to authenticated
using (cliente_id = auth.uid() or e_da_equipe());
drop policy if exists agendamentos_marcar on agendamentos;
create policy agendamentos_marcar on agendamentos for insert to authenticated
with check (cliente_id = auth.uid() and status = 'marcado');
-- A recepção marca por telefone: o agendamento fica no login dela, com o nome do cliente.
drop policy if exists agendamentos_equipe_marca on agendamentos;
create policy agendamentos_equipe_marca on agendamentos for insert to authenticated
with check (e_da_equipe() and status = 'marcado');
drop policy if exists agendamentos_cliente_cancela on agendamentos;
create policy agendamentos_cliente_cancela on agendamentos for update to authenticated
using (cliente_id = auth.uid() and status in ('marcado', 'confirmado'))
with check (cliente_id = auth.uid() and status = 'cancelado');
drop policy if exists agendamentos_equipe on agendamentos;
create policy agendamentos_equipe on agendamentos for update to authenticated
using (e_da_equipe()) with check (e_da_equipe());
-- Privilégios: o mínimo. Sem delete para ninguém (cancelar é mudar o status).
revoke all on equipe, servicos, profissionais, agendamentos from anon, authenticated;
grant select on servicos, profissionais to anon, authenticated;
grant insert, update, delete on servicos, profissionais to authenticated;
grant select on equipe to authenticated;
grant select on agendamentos to authenticated;
grant insert (cliente_nome, servico_id, profissional_id, inicio) on agendamentos to authenticated;
grant update (status) on agendamentos to authenticated;
-- Horários ocupados de um profissional num dia, sem dizer de quem são.
-- É o que a tela de marcar usa para mostrar só os horários livres.
create or replace function horarios_ocupados(p_profissional bigint, p_dia date)
returns table (inicio timestamptz, fim timestamptz)
language sql stable security definer set search_path from current as $$
select a.inicio, a.fim
from agendamentos a
where a.profissional_id = p_profissional
and a.status <> 'cancelado'
and a.inicio >= (p_dia::timestamp at time zone 'America/Sao_Paulo')
and a.inicio < ((p_dia + 1)::timestamp at time zone 'America/Sao_Paulo')
order by a.inicio
$$;
revoke all on function horarios_ocupados(bigint, date) from public;
grant execute on function horarios_ocupados(bigint, date) to anon, authenticated;
-- Lembretes: marca e devolve, numa operação só, quem tem horário nas
-- próximas 24 horas e ainda não foi lembrado. Só o seu servidor chama.
create or replace function proximos_lembretes()
returns table (agendamento_id uuid, cliente_id uuid, cliente_nome text,
servico text, inicio timestamptz)
language sql volatile security definer set search_path from current as $$
update agendamentos a
set lembrete_enviado_em = now()
from servicos s
where s.id = a.servico_id
and a.status in ('marcado', 'confirmado')
and a.lembrete_enviado_em is null
and a.inicio between now() and now() + interval '24 hours'
returning a.id, a.cliente_id, a.cliente_nome, s.nome, a.inicio
$$;
revoke all on function proximos_lembretes() from public, anon, authenticated;
grant execute on function proximos_lembretes() to service_role;
Por que as funções são security definer: o cliente não enxerga os agendamentos dos outros, mas a conferência de horário precisa enxergar. A função roda com a permissão do dono do projeto e devolve só o necessário: um sim ou não, ou o início e o fim, sem nome. Para entender a fundo, veja RLS explicado.
Passo 3: cadastre serviços, profissionais e a equipe
Rode no SQL Editor com os dados do negócio. A dona entra na equipe com o id do usuário dela, que aparece no painel em Autenticação → Usuários depois do primeiro login.
insert into servicos (nome, duracao_min, preco)
select * from (values ('Corte', 45, 60.00), ('Escova', 30, 45.00)) v(nome, duracao_min, preco)
where not exists (select 1 from servicos);
insert into profissionais (nome)
select * from (values ('Ana'), ('Bruno')) v(nome)
where not exists (select 1 from profissionais);
-- troque pelo id do login da dona
insert into equipe (user_id, nome, papel)
values ('00000000-0000-0000-0000-000000000000', 'Dona do salão', 'dono')
on conflict (user_id) do nothing;
Passo 4: conecte o app
Instale o SDK com npm install @superdb/client. Ele tem os mesmos nomes de método do supabase-js. Crie o cliente uma vez:
// src/lib/superdb.ts
import { createClient } from '@superdb/client'
// A anon key é pública e pode ficar no front. A service_role, nunca.
export const db = createClient('https://auth.superdb.com.br', 'SUA_ANON_KEY', {
project: 'SEU_SLUG',
})
Estas são as funções que as telas chamam: catálogo, horários livres, marcar, a lista de cada um, cancelar e, para a equipe, confirmar.
// src/lib/agenda.ts: as chamadas que as telas do app usam.
// db = createClient('https://auth.superdb.com.br', ANON_KEY, { project: 'SEU_SLUG' })
import type { SupabaseClient } from '@superdb/client'
export type Servico = { id: number; nome: string; duracao_min: number; preco: number }
export type Profissional = { id: number; nome: string }
// Tela inicial: o catálogo aparece mesmo para quem ainda não entrou.
export async function catalogo(db: SupabaseClient) {
const [servicos, profissionais] = await Promise.all([
db.from('servicos').select('id,nome,duracao_min,preco').order('nome'),
db.from('profissionais').select('id,nome').order('nome'),
])
if (servicos.error) throw servicos.error
if (profissionais.error) throw profissionais.error
return { servicos: servicos.data as Servico[], profissionais: profissionais.data as Profissional[] }
}
// Horários livres de um dia: grade de 30 em 30 minutos menos os ocupados.
export async function horariosLivres(
db: SupabaseClient, profissionalId: number, dia: string, // dia = '2026-10-22'
duracaoMin: number, abre = '09:00', fecha = '18:00',
) {
const { data, error } = await db.rpc('horarios_ocupados', { p_profissional: profissionalId, p_dia: dia })
if (error) throw error
const ocupados = (data as { inicio: string; fim: string }[]).map((o) => [Date.parse(o.inicio), Date.parse(o.fim)])
const livres: string[] = []
// -03:00: horário de Brasília (sem horário de verão desde 2019).
for (let t = Date.parse(`${dia}T${abre}:00-03:00`); t + duracaoMin * 60_000 <= Date.parse(`${dia}T${fecha}:00-03:00`); t += 30 * 60_000) {
const fim = t + duracaoMin * 60_000
if (t > Date.now() && !ocupados.some(([i, f]) => t < f && fim > i)) livres.push(new Date(t).toISOString())
}
return livres
}
// Marcar: o banco calcula o fim e recusa horário ocupado.
export async function marcar(db: SupabaseClient, a: {
cliente_nome: string; servico_id: number; profissional_id: number; inicio: string
}) {
const { data, error } = await db.from('agendamentos').insert(a).select('id,inicio,fim,status').single()
if (error) {
if (error.message.includes('horario_ocupado')) throw new Error('Esse horário acabou de ser ocupado. Escolha outro.')
throw error
}
return data
}
// Os agendamentos que a pessoa enxerga: o cliente vê os dele; a equipe, todos.
export async function meusAgendamentos(db: SupabaseClient) {
const { data, error } = await db
.from('agendamentos')
.select('id,cliente_nome,inicio,fim,status,servicos(nome),profissionais(nome)')
.neq('status', 'cancelado')
.order('inicio')
if (error) throw error
return data
}
export async function cancelar(db: SupabaseClient, id: string) {
const { error } = await db.from('agendamentos').update({ status: 'cancelado' }).eq('id', id)
if (error) throw error
}
// Só a equipe: confirmar ou marcar como concluído.
export async function mudarStatus(db: SupabaseClient, id: string, status: 'confirmado' | 'concluido') {
const { data, error } = await db.from('agendamentos').update({ status }).eq('id', id).select('id,status')
if (error) throw error
if (!data?.length) throw new Error('Sem permissão para alterar este agendamento.')
return data[0]
}
As telas mínimas: Início (catálogo, sem login), Marcar (serviço, profissional, dia e os botões de horariosLivres), Meus horários (com cancelar) e Agenda para quem está na equipe (a mesma meusAgendamentos, que para a equipe devolve todos, com confirmar). Para saber se mostra a Agenda, leia a própria linha em equipe: db.from('equipe').select('papel').maybeSingle().
Passo 5: login por código
No Brasil, o código pelo WhatsApp é o que menos trava o cadastro: chega onde a pessoa já está olhando. Ele faz parte dos planos Pro e Escala. Nos outros, o mesmo fluxo funciona com o código no e-mail, trocando o channel.
// Pro e Escala: código de 6 dígitos no WhatsApp
await db.auth.signInWithOtp({ phone: '+5531999998888', options: { channel: 'whatsapp' } })
await db.auth.verifyOtp({ phone: '+5531999998888', code: '123456' })
// Qualquer plano: o mesmo código, por e-mail
await db.auth.signInWithOtp({ email: 'cliente@exemplo.com.br' })
await db.auth.verifyOtp({ email: 'cliente@exemplo.com.br', code: '123456' })
Mande o telefone no formato +55DDDNÚMERO nas duas chamadas. A máscara de telefone, o reenvio e o que fazer quando o número não tem WhatsApp estão em WhatsApp OTP; o código por e-mail, em Login sem senha. No teste deste roteiro, o pedido pelo WhatsApp num projeto Grátis voltou 403 whatsapp_not_in_plan, sem enviar nada.
Passo 6: lembretes
A função proximos_lembretes marca e devolve, de uma vez, quem tem horário nas próximas 24 horas e ainda não foi lembrado. Duas rodadas seguidas não lembram duas vezes. Só a chave de servidor chama a função: no teste, visitante e cliente logado receberam permission denied.
// lembretes.ts: roda no seu servidor ou num agendador (a cada 15 minutos).
// Usa a chave de servidor: nunca vai para o app.
import { createClient } from '@superdb/client'
const URL = 'https://auth.superdb.com.br'
const SERVICE = process.env.SUPERDB_SERVICE_ROLE_KEY!
const PROJETO = process.env.SUPERDB_PROJECT!
export async function enviarLembretes() {
const admin = createClient(URL, SERVICE, { project: PROJETO })
// Marca e devolve de uma vez: duas rodadas seguidas não lembram duas vezes.
const { data, error } = await admin.rpc('proximos_lembretes')
if (error) throw error
const lista = data as { agendamento_id: string; cliente_id: string; cliente_nome: string; servico: string; inicio: string }[]
const envios: string[] = []
for (const a of lista) {
const hora = new Date(a.inicio).toLocaleString('pt-BR', {
timeZone: 'America/Sao_Paulo', weekday: 'long', hour: '2-digit', minute: '2-digit',
})
const r = await fetch(`${URL}/push/v1/enviar`, {
method: 'POST',
headers: { Authorization: `Bearer ${SERVICE}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
destinatarios: [a.cliente_id],
titulo: 'Lembrete do seu horário',
corpo: `${a.cliente_nome}, ${a.servico} ${hora}. Precisa remarcar? Abra o app.`,
dados: { agendamento: a.agendamento_id },
opcoes: { agrupar: a.agendamento_id },
}),
})
const recibo = await r.json()
envios.push(recibo.id)
}
return { lembrados: lista.length, envios }
}
if (import.meta.main) console.log(await enviarLembretes())
O envio usa o Push do SuperDB, que entrega no iPhone e no Android quando o app registra o aparelho (app com Expo, por exemplo). Push no navegador ainda não existe: se o seu app é só web, troque o fetch do push pelo envio do canal que o seu negócio já usa. O SuperDB não agenda tarefas nem tem pg_cron; quem chama o script na hora certa é um agendador seu, como este:
# .github/workflows/lembretes.yml
name: lembretes
on:
schedule:
- cron: '*/15 * * * *'
jobs:
enviar:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install @superdb/client && bun lembretes.ts
env:
SUPERDB_SERVICE_ROLE_KEY: ${{ secrets.SUPERDB_SERVICE_ROLE_KEY }}
SUPERDB_PROJECT: SEU_SLUG
Com IA: o prompt pronto
Se você usa Lovable, Cursor ou Claude, faça os passos 1 a 3 no painel e cole o prompt abaixo junto com o arquivo src/lib/agenda.ts. Com o MCP do SuperDB conectado, o agente também confere as tabelas e as políticas. No Lovable, siga antes o guia SuperDB no Lovable.
Crie um app de agendamento para um salão de beleza, em português, para celular.
IMPORTANTE: NÃO use o Lovable Cloud nem o Supabase. O backend é o SuperDB,
pelo pacote npm @superdb/client (mesmos nomes de método do supabase-js).
Não use @supabase/supabase-js.
As tabelas já existem no projeto SEU_SLUG (servicos, profissionais, equipe,
agendamentos) com RLS. NÃO crie nem altere tabelas e NÃO desligue a RLS.
Use o cliente de src/lib/superdb.ts e SOMENTE as funções do arquivo
src/lib/agenda.ts que vou colar abaixo, sem mudar a lógica delas.
Telas:
1. Início: lista de serviços com duração e preço (sem login).
2. Marcar: escolhe serviço, profissional e dia; mostra os horários de
horariosLivres como botões; pede o nome; chama marcar. Se der
"horário acabou de ser ocupado", recarrega os horários.
3. Login por código no e-mail com signInWithOtp e verifyOtp.
4. Meus horários: meusAgendamentos, com botão cancelar.
5. Agenda (só se db.from('equipe').select('papel').maybeSingle() trouxer
uma linha): os agendamentos do dia, com confirmar e concluído.
[cole aqui o conteúdo de src/lib/agenda.ts]
O que testar antes de entregar
Foi isto que o teste deste roteiro conferiu, com dois clientes e a dona:
- Sem login, a pessoa vê o catálogo e não vê nenhum agendamento.
- O fim do horário é calculado pelo banco (Corte de 45 min às 10:00 termina 10:45).
- Outro cliente não consegue 10:30 com a mesma profissional, mas consegue 10:45.
- Dois pedidos simultâneos no mesmo horário: um passa, o outro é recusado.
- Cliente não se confirma sozinho, não cancela o de outro, não marca em nome de outro, não muda o horário direto e não marca no passado.
- Cliente não cria serviço; a dona cria.
- A dona vê todos e confirma; cancelar libera o horário.
- O lembrete sai uma vez só, só para quem tem horário nas próximas 24 horas, e só a chave de servidor chama a função.
Quanto custa
| Plano | Preço | O que este roteiro usa do plano |
|---|---|---|
| Grátis | R$ 0 | Tudo deste roteiro, menos o código pelo WhatsApp (use o código por e-mail). 50 mil usuários ativos por mês, 1.000 push por mês e, a partir de 14/11/2026, 250 mil requisições por mês por projeto. Projeto sem uso por 30 dias é pausado e volta em 1 clique. |
| Site | R$ 39/mês | O mesmo login do Grátis (sem WhatsApp), 100 mil usuários ativos, requisições sem cota e 1.000 push por mês. Não pausa por inatividade. |
| Pro | R$ 119/mês | Código de login pelo WhatsApp, 250 mil usuários ativos e 250.000 push por mês. É o plano para lançar o app na loja. |
| Escala | R$ 349/mês | Para rede de salões ou agência com muitos clientes: 1 milhão de usuários ativos e 2.000.000 push por mês. |
Os limites completos estão em Preços. Cobrança em real, no cartão no mensal, ou PIX, boleto ou cartão no anual.
Perguntas
Dá para fazer o app de agendamento no plano Grátis?
Dá. Tabelas, login por e-mail, horários, a agenda da equipe e 1.000 push por mês estão no Grátis. O código de login pelo WhatsApp faz parte do Pro e do Escala; no Grátis o pedido responde 403 whatsapp_not_in_plan e você usa o código por e-mail.
Como o app evita dois clientes no mesmo horário?
Um gatilho no banco calcula o fim pelo tempo do serviço e recusa qualquer marcação que se sobreponha a outra do mesmo profissional, com uma trava por profissional. No teste, dois pedidos no mesmo segundo para as 14:00: um passou e o outro foi recusado.
O cliente vê os horários dos outros clientes?
Não. Cada cliente lê só os próprios agendamentos. A tela de marcar usa a função horarios_ocupados, que devolve só início e fim, sem nome de ninguém.
O SuperDB manda o lembrete pelo WhatsApp?
Não. O WhatsApp do SuperDB envia só o código de login, com texto fixo. O lembrete sai por push no app (iPhone e Android) ou pelo canal que o seu servidor já usa: a função proximos_lembretes entrega a lista e o seu agendador manda.
Serve para clínica?
Serve para a agenda. Prontuário e dado de saúde são dado pessoal sensível na LGPD: não guarde nada disso em cliente_nome ou numa coluna de observação sem antes decidir quem pode ler e por quanto tempo.