Files
Alexandre Possebom 1df9e81ec1
Build and Push Docker Image / build (push) Successful in 4m33s
Continuous integration / Check (push) Successful in 1m40s
Continuous integration / Test Suite (push) Successful in 2m34s
Continuous integration / Rustfmt (push) Successful in 27s
Continuous integration / Clippy (push) Successful in 1m35s
feat(calendar): publish a professional's agenda as a feed
The Professional subscribes to their own agenda in Apple Calendar or
Google Calendar through a secret URL that serves iCalendar (RFC 5545).
No OAuth, no writing into their account: the feed is the complete state,
so cancelling makes the event disappear on the next fetch. See ADR 0006.

- `professionals.calendar_token`, nullable and UNIQUE, born on the first
  request through the same idempotent COALESCE as the access code.
  26 symbols of the existing unambiguous alphabet, ~129 bits: this URL
  lives for years on someone else's server.
- `src/icalendar.rs` is pure, like `availability.rs`: folding at 75
  octets without splitting a UTF-8 sequence, TEXT escaping, UTC
  instants, stable UID so rescheduling moves the event. Do not copy the
  frontend's `ics.ts`, which never folds.
- `DTSTAMP` is the appointment's `updated_at`, never `now()`: with the
  request time the body would change on every fetch and the ETag would
  never match, so the 304 would not exist. Apple fetches hourly per
  subscriber.
- `confirmed`, `completed` and `no_show` only, from 90 days back into
  the open future. The status list is a Rust constant handed to the
  repository instead of a literal buried in SQL, so the ADR decision is
  readable and testable.
- The feed answers the same 404 for an unknown token and for a
  deactivated Professional, and the three panel routes go through
  `require_manage_professional`.

The public route is `/calendar/{token}/feed.ics`, not `{token}.ics`:
matchit 0.8 does not support dynamic suffixes, and the other shape
panics the Router at boot instead of failing to compile.

24 unit tests and one against Postgres, which is where the status filter
and the window actually run. Also verified end to end against a real
server: 3 events out of 8, a client name with `;` and `,` escaped, no
line over 75 octets, 304 on `if-none-match`, rotation killing the old
URL, and the email landing in mailpit.

Two ROADMAP debt entries were stale and are corrected with the evidence:
clippy passes clean and the dbml is current.
2026-07-30 09:21:10 -03:00

11 KiB

CLAUDE.md

O que é

API de agendamento multi-empresa. Cada empresa cadastra seus profissionais e seus serviços, e o cliente final marca horário pela internet, sem criar conta. Backend Rust/axum + Postgres. O frontend vive em repositório separado (React + shadcn, build Vite estático).

  • Vocabulário do domínio: CONTEXT.md
  • Decisões que não são óbvias no código: docs/adr/
  • O que está pronto, o que falta e em que ordem: docs/ROADMAP.md
  • O que está quebrado, o que já foi consertado e onde: BUGS.md

Setup

Postgres local, uma vez só:

psql -h localhost -U $USER -d postgres -c "CREATE ROLE agenda WITH LOGIN PASSWORD 'suasenha'; ALTER ROLE agenda CREATEDB;"
psql -h localhost -U $USER -d postgres -c "CREATE DATABASE agenda OWNER agenda;"
cp .env.example .env    # preencha JWT_SECRET com: openssl rand -base64 32

As migrations rodam sozinhas quando o servidor sobe (src/main.rs). Sem JWT_SECRET no ambiente o processo dá panic no primeiro uso do token, de propósito.

Comandos

Comando Para quê
SQLX_OFFLINE=true cargo test --bins 361 testes unitários, não precisa de banco
cargo test --test db_constraints 23 testes de constraint, exigem Postgres e DATABASE_URL
/usr/local/bin/container run -d --name mailpit -p 1025:1025 -p 8025:8025 docker.io/axllent/mailpit catcher de e-mail em dev (apple container; exige o serviço de pé: container system start); caixa em http://localhost:8025, API em /api/v1/messages
cargo run sobe em :3000 e aplica as migrations
cargo sqlx prepare -- --all-targets regenera .sqlx/ depois de mexer em SQL (precisa do banco de pé)
./clear_db.sh dropa e recria o banco do zero
uvx --from robotframework --with robotframework-requests robot tests/api/ testes de API de ponta a ponta, exigem servidor rodando (o binário robot não está instalado nesta máquina, só via uvx)

cargo test sem argumento roda os dois conjuntos, e então precisa de banco. O #[sqlx::test] cria um banco descartável por teste e roda ./migrations nele, portanto o usuário do DATABASE_URL precisa de CREATEDB.

Arquitetura

Três camadas, com um trait no meio para permitir mock:

src/controllers/<x>.rs   handler axum: valida entrada, checa permissão, chama o repositório
src/repositories/<x>.rs  trait <X>Repository + implementação Pg<X>Repository (sqlx)
src/models/<x>.rs        structs serde + sqlx::FromRow
src/availability.rs      o cálculo de slots: função pura, sem banco

src/availability.rs é deliberadamente puro: recebe janelas, ocupados e parâmetros já resolvidos e devolve horários. É a parte mais fácil de errar do sistema (fuso, virada de dia, borda de janela), e sendo pura dá para cobrir cada borda sem subir Postgres. Quem busca no banco é controllers/availability.rs::compute_slots. Mantenha essa separação: lógica nova de horário entra no módulo puro, não no controller.

  • AppState (src/main.rs) guarda os repositórios como Arc<dyn Trait>. É isso que deixa o teste injetar Mock<X>Repository (mockall): nenhum teste unitário toca no banco.
  • Autenticação é o extractor Claims (src/utils.rs). Handler que recebe claims: Claims exige Authorization: Bearer; handler sem esse parâmetro é rota pública. É assim que se lê, olhando a assinatura, se algo está exposto. Todo controllers/public.rs é público de propósito, mais auth::login, auth::refresh e controllers/webhook.rs (barrado por X-Webhook-Secret, não por JWT).
  • O cliente final nunca autentica. Ele acessa o próprio agendamento por um link com o código de acesso curto (appointments.access_code, o /a/{code} das mensagens; ADR 0005). As rotas de /appointment/{token} também aceitam o JWT legado (src/magic_link.rs, MAGIC_LINK_SECRET, separado do JWT_SECRET; não unifique: um segredo só para dois públicos faz a separação depender de o código lembrar de checar a claim certa). O token de confirmação do e-mail segue JWT com purpose próprio (appointment_confirm): confirmar não abre nem cancela, e vice-versa.
  • Agendamento público nasce pending e só confirma quando o cliente prova a posse do canal: código pelo WhatsApp (quem envia a mensagem é o CLIENTE, via link wa.me; o servidor só responde) ou clique no link do e-mail. Racional, estados e prazos no ADR 0004. Painel autenticado cria confirmed direto.
  • Esse mesmo extractor grava last_activity, ou seja, todo request autenticado faz um UPDATE.
  • A política de autorização vive inteira em src/authz.rs: enum Role (papel desconhecido cai no menor privilégio) e helpers require_* em Claims. Cada handler chama a checagem explicitamente; não existe middleware de permissão, e string de papel não decide mais nada.

Convenções

  • SQL é escrito à mão nas macros sqlx::query! / query_as!, validadas contra o banco em tempo de compilação. Mexeu em query, rode cargo sqlx prepare com o banco de pé e commite o .sqlx/, senão o CI (que roda com SQLX_OFFLINE=true) quebra.
  • Rota nova entra em routes() (src/main.rs), handler em src/controllers/.
  • Erro de handler é sempre AppError (src/error.rs), que já implementa IntoResponse. Não devolva StatusCode cru.
  • Teste de controller segue sempre o mesmo molde: monta os mocks, monta o AppState, routes().with_state(...), oneshot(Request::...), e confere status e corpo JSON.
  • Para provar que um handler não escreve, deixe o mock sem a expectativa: o mockall derruba o teste se a chamada acontecer. É o que faz um teste de permissão valer alguma coisa, em vez de só conferir o status.

Armadilhas verificadas

  • Nunca crave data em teste de agendamento. Os handlers rejeitam start no passado (src/controllers/appointment.rs, create e update), então data fixa passa hoje e apodrece com o relógio. Três testes já morreram assim. Use o helper future_datetime(hora, minuto) do módulo de testes de appointment.rs.

  • Só opção estável no rustfmt.toml, e é de propósito. O rustfmt estável não falha em opção de nightly: avisa e formata com o padrão dele. Enquanto o arquivo pedia dez delas, a árvore formatada em nightly nunca batia com o --check do CI. Se alguma voltar, o job de fmt tem que ir para nightly junto. Hoje cargo fmt e cargo clippy -- -D warnings passam limpos.

  • Nunca use a data UTC para achar o dia da grade. 22:00 em São Paulo já é o dia seguinte em UTC, então start.date_naive() procura a grade do dia errado e recusa horário válido. Use company_local_date.

  • A validação de slot só roda quando service_id vem preenchido. Sem serviço, o agendamento ainda cai só nas checagens antigas (passado, sobreposição). O painel consegue marcar fora da grade de propósito, e isso é intencional: encaixe existe.

  • A checagem de conflito não é a garantia: quem impede sobreposição é a constraint appointments_no_overlap (ADR 0002). Create e update conferem via get_busy_between, cujo WHERE (status IN ('pending', 'confirmed') + janela) precisa espelhar o da constraint: se divergir, a aplicação recusa horário que o banco aceitaria. Desde o ADR 0004 o espelho cobre dois estados; mexeu num, mexa nos dois.

  • Teste verde aqui não quer dizer que funciona. Os testes mockam os repositórios e montam routes() via oneshot, então nunca executam SQL e nunca sobem o main.rs. Três bugs sérios desta base passaram por 129 testes verdes:

    • o rate limit derrubava toda rota pública com 500 (faltava ConnectInfo no main.rs);
    • o INSERT de appointment descartava company_id, service_id e description;
    • por causa disso, agendamento de qualquer empresa era gravado como empresa 1.

    Mexeu em main.rs ou em SQL: suba o servidor, faça a operação por curl, e confira no banco o que foi realmente gravado. É a única coisa que pega essa classe de erro.

  • Coluna nova no INSERT, não só no model. Adicionar campo ao struct não faz o SQL gravá-lo, e o query! não reclama de coluna omitida. Foi assim que service_id ficou NULL por uma fase inteira.

  • Rota pública nova precisa de DTO reduzido. Professional carrega e-mail, telefone, role e last_activity. Devolver o model cru numa rota pública vaza tudo isso; use os Public* de controllers/public.rs.

  • A mensagem de erro da API mente: leia o log do servidor. Os handlers mapeiam qualquer falha do repositório para uma variante fixa de AppError, e 41 map_err(|_| ...) descartam a causa. Hoje POST /professional respondeu "Professional already exists" quando o erro real era duplicate key ... professionals_pkey, e POST /company respondeu "an internal server error" para uma coluna NOT NULL faltando. O que salva é o log: DataAccessError::from_sqlx (src/error.rs) classifica pelo SQLSTATE e registra por tracing, com a operação que falhou junto da causa. Investigou erro 4xx/5xx aqui? Suba o servidor, reproduza e leia o log, nunca o corpo da resposta. Nos repositórios em anyhow (agendamento, disponibilidade, serviço) a causa ainda se perde: lá só os dois caminhos de criação de agendamento passam pelo classificador.

  • request.http e docs/database.dbml foram reescritos contra a realidade em 2026-07-28. Mudou rota ou schema, atualize-os na mesma mudança: a versão anterior do dbml (2023) descrevia colunas que não existiam mais e já custou uma migration quebrada.

  • Commit que toca um controller quase nunca builda sozinho. Todo teste de controller constrói o AppState inteiro, então mexer em um arraste o resto: mudar a struct quebra os outros seis arquivos. Antes de afirmar que um commit intermediário compila, verifique de verdade: git worktree add --detach <dir> <sha> && cd <dir> && SQLX_OFFLINE=true cargo test --bins. Eu afirmei duas vezes sem verificar e estava errado nas duas.

  • ALTER TABLE ... RENAME não renomeia sequência de identity nem constraint. Depois do rename da fase 1 a tabela professionals ainda era servida por employees_id_seq, e as constraints mantinham os nomes antigos. Renomeie explicitamente na mesma migration.

  • Seed com id explícito deixa a sequência parada. Com GENERATED BY DEFAULT AS IDENTITY o Postgres não avança o contador quando o id vem de fora, então o primeiro INSERT num banco recém-migrado colide com a linha do seed e só o segundo funciona. Migration nova que semeia id fixo precisa de setval(pg_get_serial_sequence(...), max(id)) junto.

  • sed em bloco de use acerta o mod tests também. Cada controller importa os mesmos caminhos duas vezes, no topo e dentro dos testes, e replace(..., 1) pega a ocorrência errada. Ancore no texto completo do bloco do topo e confira onde caiu.

  • Vários testes sombreiam professional: o primeiro é o alvo, o segundo é quem chama. Como o extractor lê papel e empresa do banco, o mock de update_last_activity tem que devolver o mesmo que o JWT daquele teste diz. Script que lê a última atribuição do corpo pega o alvo e dá à chamada a empresa errada: quebrou 4 testes e deixou 1 passando pelo motivo errado. Só valem as atribuições entre a última declaração da variável e a linha do to_jwt().