Pular para o conteúdo
📅 ROTEIRO 1

Como criar um app de agendamento para salão ou clínica

O cliente escolhe o serviço, o profissional e um horário livre, entra com um código e recebe lembrete. A dona do salão vê a agenda do dia e confirma. Neste roteiro você monta o banco com regras que impedem horário duplicado e cliente lendo o que não é dele, e liga o app com o SDK ou com um prompt pronto.

  • ⏱️ cerca de 2 horas
  • 🧩 Lovable, Cursor, Claude ou código à mão
  • 💸 começa no Grátis
  • ✅ testado em 21/10/2026

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

TabelaO que guardaQuem lêQuem altera
servicosnome, duração em minutos, preçoqualquer visitante (os ativos)só a dona
profissionaisquem atendequalquer visitante (os ativos)só a dona
equipelogins da equipe e o papel (dono, recepcao, profissional)cada um a própria linhavocê, pelo SQL Editor ou pelo servidor
agendamentoscliente, serviço, profissional, início, fim, statuso cliente os dele; a equipe todoso 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_confere soma 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_id nem 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.

Criar conta grátis

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.

superdb/agenda.sql
-- 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.

SQL Editor: dados de exemplo
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
// 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
// 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.

tela de login
// 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 (no servidor)
// 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:

agendador (exemplo, fora do teste)
# .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.

prompt para Lovable, Cursor ou Claude
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

PlanoPreçoO que este roteiro usa do plano
GrátisR$ 0Tudo 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.
SiteR$ 39/mêsO 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.
ProR$ 119/mêsCó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.
EscalaR$ 349/mêsPara 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.

Monte a agenda do seu cliente hoje

Crie a conta, cole o SQL do Passo 2 e o banco já recusa horário duplicado. O plano Grátis basta para construir e testar.