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.
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 comoArc<dyn Trait>. É isso que deixa o teste injetarMock<X>Repository(mockall): nenhum teste unitário toca no banco.- Autenticação é o extractor
Claims(src/utils.rs). Handler que recebeclaims: ClaimsexigeAuthorization: Bearer; handler sem esse parâmetro é rota pública. É assim que se lê, olhando a assinatura, se algo está exposto. Todocontrollers/public.rsé público de propósito, maisauth::login,auth::refreshecontrollers/webhook.rs(barrado porX-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 doJWT_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 compurposepróprio (appointment_confirm): confirmar não abre nem cancela, e vice-versa. - Agendamento público nasce
pendinge 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 criaconfirmeddireto. - 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: enumRole(papel desconhecido cai no menor privilégio) e helpersrequire_*emClaims. 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, rodecargo sqlx preparecom o banco de pé e commite o.sqlx/, senão o CI (que roda comSQLX_OFFLINE=true) quebra. - Rota nova entra em
routes()(src/main.rs), handler emsrc/controllers/. - Erro de handler é sempre
AppError(src/error.rs), que já implementaIntoResponse. Não devolvaStatusCodecru. - 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
startno 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 helperfuture_datetime(hora, minuto)do módulo de testes deappointment.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--checkdo CI. Se alguma voltar, o job de fmt tem que ir para nightly junto. Hojecargo fmtecargo clippy -- -D warningspassam 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. Usecompany_local_date. -
A validação de slot só roda quando
service_idvem 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 viaget_busy_between, cujoWHERE(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()viaoneshot, então nunca executam SQL e nunca sobem omain.rs. Três bugs sérios desta base passaram por 129 testes verdes:- o rate limit derrubava toda rota pública com 500 (faltava
ConnectInfonomain.rs); - o
INSERTde appointment descartavacompany_id,service_idedescription; - por causa disso, agendamento de qualquer empresa era gravado como empresa 1.
Mexeu em
main.rsou em SQL: suba o servidor, faça a operação porcurl, e confira no banco o que foi realmente gravado. É a única coisa que pega essa classe de erro. - o rate limit derrubava toda rota pública com 500 (faltava
-
Coluna nova no
INSERT, não só no model. Adicionar campo ao struct não faz o SQL gravá-lo, e oquery!não reclama de coluna omitida. Foi assim queservice_idficou NULL por uma fase inteira. -
Rota pública nova precisa de DTO reduzido.
Professionalcarrega e-mail, telefone,roleelast_activity. Devolver o model cru numa rota pública vaza tudo isso; use osPublic*decontrollers/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 41map_err(|_| ...)descartam a causa. HojePOST /professionalrespondeu"Professional already exists"quando o erro real eraduplicate key ... professionals_pkey, ePOST /companyrespondeu"an internal server error"para uma colunaNOT NULLfaltando. O que salva é o log:DataAccessError::from_sqlx(src/error.rs) classifica pelo SQLSTATE e registra portracing, 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 emanyhow(agendamento, disponibilidade, serviço) a causa ainda se perde: lá só os dois caminhos de criação de agendamento passam pelo classificador. -
request.httpedocs/database.dbmlforam 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
AppStateinteiro, 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 ... RENAMEnão renomeia sequência de identity nem constraint. Depois do rename da fase 1 a tabelaprofessionalsainda era servida poremployees_id_seq, e as constraints mantinham os nomes antigos. Renomeie explicitamente na mesma migration. -
Seed com
idexplícito deixa a sequência parada. ComGENERATED BY DEFAULT AS IDENTITYo 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 desetval(pg_get_serial_sequence(...), max(id))junto. -
sedem bloco deuseacerta omod teststambém. Cada controller importa os mesmos caminhos duas vezes, no topo e dentro dos testes, ereplace(..., 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 deupdate_last_activitytem 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 doto_jwt().